diff --git a/.buildkite/build-linux.sh b/.buildkite/build-linux.sh new file mode 100755 index 000000000..5e41bd731 --- /dev/null +++ b/.buildkite/build-linux.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail +# Build the Linux vip-next binaries + checksums on a Buildkite Linux agent. +# Linux has no OS-enforced executable signature; we publish checksums (a detached +# GPG/cosign signature is optional — see the bottom of this file). + +[ -f .buildkite/shared-pipeline-vars ] && . .buildkite/shared-pipeline-vars +: "${BIN_BASE:=vip-next}" + +command -v go >/dev/null 2>&1 || { echo "go not found; agent must provide Go ${GO_VERSION:-1.27}+" >&2; exit 1; } +go version + +VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" +COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +build() { + local goarch="$1" out="dist/${BIN_BASE}-linux-${goarch}" + echo "--- :go: build linux/${goarch}" + CGO_ENABLED=0 GOOS=linux GOARCH="${goarch}" \ + go build -buildvcs=false -trimpath \ + -ldflags="-s -w -X github.com/Automattic/vip/internal/version.Version=${VERSION} -X github.com/Automattic/vip/internal/version.Commit=${COMMIT}" \ + -o "${out}" ./cmd/vip-next + shasum -a 256 "${out}" > "${out}.sha256" +} + +mkdir -p dist +build amd64 +build arm64 + +# Smoke-test only the arch matching this agent (a cross-built slice won't run here). +case "$(uname -m)" in + x86_64|amd64) native=amd64 ;; + aarch64|arm64) native=arm64 ;; + *) native="" ;; +esac +if [ -n "${native}" ]; then + echo "--- :test_tube: smoke linux/${native}" + "dist/${BIN_BASE}-linux-${native}" --version + "dist/${BIN_BASE}-linux-${native}" whoami --help +fi + +# Optional detached signature (needs an infra-owned key); checksums-only by default. +# gpg --armor --detach-sign "dist/${BIN_BASE}-linux-amd64" # ← infra: enable if desired diff --git a/.buildkite/build-macos.sh b/.buildkite/build-macos.sh new file mode 100755 index 000000000..77d1bf060 --- /dev/null +++ b/.buildkite/build-macos.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail +# Build, and on tag builds sign + notarize, the macOS vip-next artifacts, on a +# Buildkite macOS agent (queue: mac): +# - two bare per-arch binaries: codesigned + notarized (online-verified; a bare +# Mach-O can't be stapled) +# - one universal .pkg installer: codesigned + productsigned + notarized + STAPLED +# (offline-verified) +# Checksums are written AFTER signing (signing changes the bytes). + +[ -f .buildkite/shared-pipeline-vars ] && . .buildkite/shared-pipeline-vars +: "${BIN_BASE:=vip-next}" + +echo "--- :ruby: install gems" +if command -v install_gems >/dev/null 2>&1; then install_gems; else bundle install; fi + +echo "--- :go: toolchain" +command -v go >/dev/null 2>&1 || { echo "go not found; agent must provide Go ${GO_VERSION:-1.27}+" >&2; exit 1; } +go version + +VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" +COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +build() { + local goarch="$1" out="dist/${BIN_BASE}-darwin-${goarch}" + echo "--- :go: build darwin/${goarch}" + CGO_ENABLED=0 GOOS=darwin GOARCH="${goarch}" \ + go build -buildvcs=false -trimpath \ + -ldflags="-s -w -X github.com/Automattic/vip/internal/version.Version=${VERSION} -X github.com/Automattic/vip/internal/version.Commit=${COMMIT}" \ + -o "${out}" ./cmd/vip-next +} + +checksum() { shasum -a 256 "$1" > "$1.sha256"; } + +mkdir -p dist +build arm64 +build amd64 + +# Smoke-test the native arch (a cross-built slice may not run without Rosetta). +case "$(uname -m)" in + arm64) native=arm64 ;; + x86_64) native=amd64 ;; + *) native="" ;; +esac +if [ -n "${native}" ]; then + echo "--- :test_tube: smoke darwin/${native}" + "dist/${BIN_BASE}-darwin-${native}" --version + "dist/${BIN_BASE}-darwin-${native}" whoami --help +fi + +if [ -z "${BUILDKITE_TAG:-}" ]; then + echo "--- not a tag build; skipping sign/notarize (unsigned checksums only)" + checksum "dist/${BIN_BASE}-darwin-arm64" + checksum "dist/${BIN_BASE}-darwin-amd64" + exit 0 +fi + +echo "--- :closed_lock_with_key: fetch signing certs (fastlane match)" +bundle exec fastlane configure_code_signing + +# Build the universal binary from the UNSIGNED arches, then sign all three once. +uni="dist/${BIN_BASE}-darwin-universal" +lipo -create -output "${uni}" "dist/${BIN_BASE}-darwin-arm64" "dist/${BIN_BASE}-darwin-amd64" + +for bin in "dist/${BIN_BASE}-darwin-arm64" "dist/${BIN_BASE}-darwin-amd64" "${uni}"; do + echo "--- :closed_lock_with_key: codesign ${bin}" + codesign --remove-signature "${bin}" 2>/dev/null || true # drop Go's ad-hoc sig + codesign --sign "${MACOS_SIGN_IDENTITY}" --options runtime --timestamp --force "${bin}" + codesign --verify --strict --verbose=2 "${bin}" +done + +# Bare binaries: notarize (no staple — nothing to hold the ticket), then checksum. +for arch in arm64 amd64; do + bin="dist/${BIN_BASE}-darwin-${arch}" + echo "--- :cloud: notarize ${arch} (no staple)" + ditto -c -k --keepParent "${bin}" "${bin}.zip" + bundle exec fastlane notarize_artifact path:"${bin}.zip" + rm -f "${bin}.zip" + checksum "${bin}" +done + +# Universal .pkg: package the signed universal binary → sign the pkg → notarize + staple. +echo "--- :package: build + sign universal .pkg" +pkgroot="$(mktemp -d)" +cp "${uni}" "${pkgroot}/${BIN_BASE}" +pkg="dist/${BIN_BASE}-darwin-universal.pkg" +pkgbuild --root "${pkgroot}" --identifier com.automattic.vip-cli --version "${VERSION}" \ + --install-location /usr/local/bin "${pkg}.unsigned" +productsign --sign "${MACOS_INSTALLER_IDENTITY}" "${pkg}.unsigned" "${pkg}" +rm -f "${pkg}.unsigned" +rm -rf "${pkgroot}" + +echo "--- :cloud: notarize + staple .pkg" +bundle exec fastlane notarize_artifact path:"${pkg}" skip_stapling:false +xcrun stapler validate "${pkg}" +checksum "${uni}" +checksum "${pkg}" diff --git a/.buildkite/build-windows.ps1 b/.buildkite/build-windows.ps1 new file mode 100644 index 000000000..c8d5865e7 --- /dev/null +++ b/.buildkite/build-windows.ps1 @@ -0,0 +1,53 @@ +#Requires -Version 5.1 +# Build vip-next.exe and (on tag builds) Authenticode-sign it, on a Buildkite +# Windows agent. Checksum is computed AFTER signing (signing changes the bytes). +$ErrorActionPreference = 'Stop' + +$binBase = if ($env:BIN_BASE) { $env:BIN_BASE } else { 'vip-next' } + +function Get-GitOr($cmd, $fallback) { + try { $v = & git @cmd 2>$null; if ($LASTEXITCODE -eq 0 -and $v) { return $v.Trim() } } catch {} + return $fallback +} +$version = Get-GitOr @('describe','--tags','--always','--dirty') 'dev' +$commit = Get-GitOr @('rev-parse','--short','HEAD') 'unknown' + +New-Item -ItemType Directory -Force -Path dist | Out-Null +$out = "dist/$binBase-windows-amd64.exe" + +Write-Host "--- :go: build windows/amd64" +$env:CGO_ENABLED = '0'; $env:GOOS = 'windows'; $env:GOARCH = 'amd64' +$ldflags = "-s -w -X github.com/Automattic/vip/internal/version.Version=$version -X github.com/Automattic/vip/internal/version.Commit=$commit" +go build -buildvcs=false -trimpath -ldflags="$ldflags" -o $out ./cmd/vip-next +if ($LASTEXITCODE -ne 0) { throw 'go build failed' } + +Write-Host "--- :test_tube: smoke" +& $out --version +& $out whoami --help + +if ($env:BUILDKITE_TAG) { + Write-Host "--- :closed_lock_with_key: Authenticode sign" + # ← infra: confirm the Windows cert mechanism. Draft = PFX-from-base64-secret, + # mirroring the current GitHub Actions workflow. EV certs can NOT use a plain + # PFX (FIPS-hardware since June 2023) — if you use Azure Trusted Signing, swap + # the two signtool lines for `signtool sign /fd SHA256 /tr /td SHA256 /dlib /dmdf $out`. + $pfxB64 = $env:WINDOWS_CERTIFICATE_PFX_BASE64 + $pfxPw = $env:WINDOWS_CERTIFICATE_PASSWORD + $ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' } + if (-not $pfxB64 -or -not $pfxPw) { throw 'tag build but WINDOWS_CERTIFICATE_PFX_BASE64 / _PASSWORD not set' } + + $pfx = Join-Path $env:TEMP 'vip-codesign.pfx' + [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($pfxB64)) + try { + signtool sign /fd SHA256 /td SHA256 /tr $ts /f $pfx /p $pfxPw $out + if ($LASTEXITCODE -ne 0) { throw 'signtool sign failed' } + signtool verify /pa /v $out + if ($LASTEXITCODE -ne 0) { throw 'signtool verify failed' } + } finally { + Remove-Item $pfx -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "--- checksum" +$hash = (Get-FileHash -Algorithm SHA256 $out).Hash.ToLower() +"$hash *$(Split-Path $out -Leaf)" | Set-Content "$out.sha256" -NoNewline diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 000000000..65cd37b1c --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,44 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json +--- +# Shared vars (CI_TOOLKIT_PLUGIN, GO_VERSION, BIN_BASE, signing identities) come +# from .buildkite/shared-pipeline-vars, which our setup source's before +# `buildkite-agent pipeline upload` interpolates this file. +# +# Every commit builds + smoke-tests all three platforms. Signing + notarization +# run only on TAG builds — gated on $BUILDKITE_TAG inside each build script. + +env: + DO_NOT_TRACK: '1' + +steps: + - label: ':macos: Build & sign (macOS)' + command: .buildkite/build-macos.sh + plugins: + - $CI_TOOLKIT_PLUGIN + agents: + queue: mac + notify: + - github_commit_status: + context: 'Build & sign (macOS)' + artifact_paths: + - 'dist/vip-next-darwin-*' + + - label: ':windows: Build & sign (Windows)' + command: powershell -NoProfile -ExecutionPolicy Bypass -File .buildkite/build-windows.ps1 + agents: + queue: windows # ← infra: confirm Windows agent queue name + notify: + - github_commit_status: + context: 'Build & sign (Windows)' + artifact_paths: + - 'dist/vip-next-windows-amd64.exe*' + + - label: ':linux: Build (Linux)' + command: .buildkite/build-linux.sh + agents: + queue: default # ← infra: confirm Linux agent queue name + notify: + - github_commit_status: + context: 'Build (Linux)' + artifact_paths: + - 'dist/vip-next-linux-*' diff --git a/.buildkite/shared-pipeline-vars b/.buildkite/shared-pipeline-vars new file mode 100644 index 000000000..c32333620 --- /dev/null +++ b/.buildkite/shared-pipeline-vars @@ -0,0 +1,21 @@ +#!/bin/sh +# This file is `source`'d before `buildkite-agent pipeline upload`, so the +# variables below get interpolated into pipeline.yml before it is uploaded. +# (Same mechanism as Automattic/download's .buildkite/shared-pipeline-vars.) + +# a8c CI toolkit Buildkite plugin — provides install_gems, secret injection, and +# GitHub status helpers on the macOS agent. +export CI_TOOLKIT_PLUGIN="automattic/a8c-ci-toolkit#5.3.1" # ← infra: confirm current version + +# Go toolchain the build scripts require. encoding/json/v2 is standard in Go 1.27. +export GO_VERSION="1.27" # ← infra: match your agent provisioning + +# Binary base name + macOS signing identities (team PZYM8XX95Q = Automattic, Inc.). +export BIN_BASE="vip-next" +export MACOS_TEAM_ID="PZYM8XX95Q" +export MACOS_SIGN_IDENTITY="Developer ID Application: Automattic, Inc. (PZYM8XX95Q)" +export MACOS_INSTALLER_IDENTITY="Developer ID Installer: Automattic, Inc. (PZYM8XX95Q)" # ← infra: confirm this cert exists + +# NOTE: unlike the reference we set no Xcode IMAGE_ID / .xcode-version — this is a +# plain Go build, no fyne/Xcode. If your mac queue requires a specific VM image, +# add: export IMAGE_ID="" # ← infra diff --git a/.github/workflows/ci-go.yml b/.github/workflows/ci-go.yml new file mode 100644 index 000000000..ec60d854f --- /dev/null +++ b/.github/workflows/ci-go.yml @@ -0,0 +1,113 @@ +name: ci-go + +on: + push: + branches: [trunk, feature/go-rewrite] + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/ci-go.yml' + - 'testdata/parity/**' + - 'testdata/parity-local/**' + - 'internal/gql/schema.gql' + - 'internal/gql/operations/**.graphql' + - 'internal/gql/generated.go' + # The Node CLI is a build input now: this job diffs vip-next against it. + # A change to Node's behaviour, or to how dist/ is produced, can break + # parity, so it has to retrigger this workflow. + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'babel.config.js' + - '.nvmrc' + pull_request: + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - '.github/workflows/ci-go.yml' + - 'testdata/parity/**' + - 'testdata/parity-local/**' + - 'internal/gql/schema.gql' + - 'internal/gql/operations/**.graphql' + - 'internal/gql/generated.go' + # See above. + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'babel.config.js' + - '.nvmrc' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.27' + check-latest: true + cache: true + + # The Node CLI is the reference implementation the differential parity + # scenarios diff vip-next against. Without it, every Node-vs-Go scenario + # skips and this job goes green having compared nothing — which is + # exactly how ~90 parity divergences reached a review unnoticed. + # + # Version comes from .nvmrc (lts/*), the same source ci.yml pins by hand. + # It must satisfy package.json#engines (>=22.19.0 on trunk 4.1.0) or the + # `postinstall` guard (helpers/check-version.js) aborts the install with + # exit 1. + - name: Set up Node.js environment + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: package-lock.json + + # `npm ci` runs the `prepare` lifecycle script (clean + babel build), + # which is what produces dist/bin/vip.js. No separate build step needed. + - name: Install Node dependencies and build the Node CLI + run: npm ci + + # Fail loudly if the above did not actually yield a runnable Node CLI. + # `make test-parity-unit` only WARNS in that case, on purpose, so that a + # contributor without node_modules is not hard-failed. CI has no such + # excuse: here a skipped differential is a broken build. + - name: Assert the Node-vs-Go differential can actually run + run: make require-node-vip-bin + + - name: go mod download + run: go mod download + + - name: Verify generated GraphQL code is fresh + run: make verify-gql-stale + + # Via make, not bare `go vet ./...` / `go test ./...`: now that `npm ci` + # has run, node_modules is inside the module and a bare `./...` would + # compile and vet an npm dependency's vendored Go package + # (node_modules/flatted/golang/pkg/flatted). The make targets discover + # the package list and drop node_modules from it. + - name: go vet + run: make lint + + - name: go test + run: make test + + # This is the step the Node CLI was installed for. On a Linux runner the + # credential the Node CLI reads comes from configstore rather than a + # system keyring (see internal/parity/keychain.go); the harness seeds + # through Node's own getKeychain(), so it lands in whichever store Node + # itself would read, and a store that cannot be driven at all produces a + # loud skip rather than a hang or a false pass. + - name: Offline compatibility and parity-harness tests + run: make test-parity-unit + + - name: make build + run: make build + + - name: smoke + run: ./bin/vip-next --version diff --git a/.gitignore b/.gitignore index 5f81d89ae..3d4aad8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,19 @@ coverage *.iml schema.gql + +# Keep the checked-in GraphQL schema despite the blanket schema.gql rule above +!internal/gql/schema.gql + +# Go build artifacts +/bin/ +*.exe +coverage.out +go.work +go.work.sum + +# Vendored go-search-replace binaries: fetched + checksum-verified by +# `make vendor-search-replace` from the pinned release in +# third_party/go-search-replace/MANIFEST (which IS tracked). Binaries stay out +# of git so the repo does not carry ~19 MB of executables per upgrade. +third_party/go-search-replace/*/ diff --git a/.prettierignore b/.prettierignore index f631526a5..3b0f5f3d2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,12 @@ /__fixtures__/ /dist/ /npm-shrinkwrap.json + +# Go tree: source, fixtures and recorded parity payloads. +# The parity recordings must stay byte-exact — reformatting them changes the +# payloads the harness replays and invalidates expected_drift signatures. +/testdata/ +/internal/ +/cmd/ +/scripts/ +/third_party/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..58cdbf207 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'fastlane', '~> 2.237' +gem 'fastlane-plugin-wpmreleasetoolkit', '~> 14.10' +# Avoids "certificate verify failed (unable to get certificate CRL)" on some hosts. +# See https://github.com/ruby/openssl/issues/949 +gem 'openssl', '~> 4.0' diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..78ac8606f --- /dev/null +++ b/Makefile @@ -0,0 +1,315 @@ +# vip-next Makefile + +GO ?= go +GOFLAGS ?= +LDFLAGS := -s -w \ + -X github.com/Automattic/vip/internal/version.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev) \ + -X github.com/Automattic/vip/internal/version.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) + +BIN_DIR := bin +BIN_NAME := vip-next + +# The Node CLI entrypoint the differential parity scenario diffs vip-next +# against. It is a BUILT artifact (`npm ci && npm run build`); when it is +# absent the scenario skips with a banner naming what is missing rather than +# failing a developer who has no node_modules. +NODE_VIP_BIN ?= $(CURDIR)/dist/bin/vip.js + +.PHONY: build search-replace-bin test test-parity test-parity-unit test-parity-unit-hostile lint tidy tidy-gql verify-gql-stale clean node-vip-bin-status require-node-vip-bin + +build: + mkdir -p $(BIN_DIR) + CGO_ENABLED=0 $(GO) build -buildvcs=false -trimpath -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(BIN_NAME) ./cmd/vip-next + @$(MAKE) --no-print-directory search-replace-bin + +# Bundle the host's go-search-replace binary next to vip-next so `import sql` +# (--search-replace) and `dev-env sync sql` resolve it without a runtime +# download. Uses the real per-platform binaries vendored under __fixtures__; +# proper release-tarball bundling of official binaries is the M8 task. +# +# This FAILS the build on a platform we have no binary for, rather than warning +# and exiting 0. Exiting 0 produced a vip-next that built fine and then died +# only when the user reached `search-replace`, `import sql --search-replace` or +# `dev-env sync sql` — i.e. the discovery moment was moved from `make build` to +# the middle of someone's import. linux/arm64 is the live gap (Graviton, ARM CI, +# Docker on Apple Silicon), and windows/arm64. +# +# Two deliberate escape hatches, because neither case is a broken setup: +# VIP_SEARCH_REPLACE_BIN= the user supplied their own binary; that is +# the first entry in searchreplace.ResolveBinary +# and it makes the bundle irrelevant. +# ALLOW_MISSING_SEARCH_REPLACE=1 the user knowingly wants a build without +# search-replace support. +# +# NOTE: __fixtures__/ is a vendored mirror of Automattic/vip and must stay +# byte-identical to it, so a new architecture CANNOT simply be dropped in there +# — the next sync would revert it. See docs/BUILD-SIGNING.md for the third_party +# plan that fixes this properly. +GSR_DIR := third_party/go-search-replace +GSR_REPO := Automattic/go-search-replace + +# Fetch the pinned go-search-replace release into $(GSR_DIR)/-/, +# verifying every file against the sha256 in $(GSR_DIR)/MANIFEST. +# +# Those digests are the SLSA provenance subjects from the upstream release +# (go-search-replace.intoto.jsonl), not values we computed. Upstream ships the +# assets GZIPPED but attests the UNCOMPRESSED binaries, so we gunzip first and +# then hash — verified against release 0.0.11. +# +# Binaries are gitignored; only MANIFEST is tracked. Upgrade with +# `make vendor-search-replace TAG=` and commit the MANIFEST diff. +# +# ALL is the release build's entry point: bundling every platform is what makes +# the shipped tarball self-contained. +vendor-search-replace: + @tag="$${TAG:-$$(awk '$$1=="TAG"{print $$2}' $(GSR_DIR)/MANIFEST)}"; \ + if [ -z "$$tag" ]; then echo "no TAG in $(GSR_DIR)/MANIFEST" >&2; exit 1; fi; \ + if [ -n "$$ALL" ]; then \ + targets=$$(awk '/^(darwin|linux|windows)\//{print $$1}' $(GSR_DIR)/MANIFEST); \ + else \ + targets="$$($(GO) env GOOS)/$$($(GO) env GOARCH)"; \ + fi; \ + tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ + for t in $$targets; do \ + os=$${t%%/*}; arch=$${t##*/}; \ + want=$$(awk -v k="$$t" '$$1==k{print $$2}' $(GSR_DIR)/MANIFEST); \ + if [ -z "$$want" ]; then echo "ERROR: $$t is not pinned in $(GSR_DIR)/MANIFEST" >&2; exit 1; fi; \ + name=go-search-replace_$${os}_$${arch}; \ + if [ "$$os" = "windows" ]; then name=$$name.exe; fi; \ + echo " fetching $$name ($$tag)"; \ + if ! gh release download "$$tag" --repo $(GSR_REPO) --pattern "$$name.gz" --dir "$$tmp" --clobber >/dev/null 2>&1; then \ + echo "ERROR: could not download $$name.gz from $(GSR_REPO)@$$tag" >&2; \ + echo " needs the gh CLI, authenticated. See docs/BUILD-SIGNING.md." >&2; exit 1; fi; \ + gunzip -f "$$tmp/$$name.gz"; \ + got=$$(shasum -a 256 "$$tmp/$$name" | cut -d' ' -f1); \ + if [ "$$got" != "$$want" ]; then \ + echo "ERROR: checksum mismatch for $$t" >&2; \ + echo " expected (from upstream SLSA provenance): $$want" >&2; \ + echo " got: $$got" >&2; \ + echo " Refusing to install. Do not bypass this." >&2; exit 1; fi; \ + out=$(GSR_DIR)/$${os}-$${arch}; mkdir -p "$$out"; \ + d=$$out/go-search-replace; if [ "$$os" = "windows" ]; then d=$$d.exe; fi; \ + mv "$$tmp/$$name" "$$d"; chmod +x "$$d"; \ + echo " verified + installed $$d"; \ + done + +search-replace-bin: + @os=$$($(GO) env GOOS); arch=$$($(GO) env GOARCH); \ + case "$$os/$$arch" in \ + darwin/arm64) f=go-search-replace-test-darwin-arm64;; \ + darwin/amd64) f=go-search-replace-test-darwin-x64;; \ + linux/amd64) f=go-search-replace-test-linux-x64;; \ + windows/amd64) f=go-search-replace-test-win32-x64.exe;; \ + *) f="";; \ + esac; \ + dest=$(BIN_DIR)/go-search-replace; \ + if [ "$$os" = "windows" ]; then dest=$$dest.exe; fi; \ + vendored=$(GSR_DIR)/$${os}-$${arch}/go-search-replace; \ + if [ "$$os" = "windows" ]; then vendored=$$vendored.exe; fi; \ + src=""; \ + if [ -f "$$vendored" ]; then src=$$vendored; \ + elif [ -n "$$f" ]; then src=__fixtures__/search-replace-binaries/$$f; fi; \ + if [ -n "$$src" ] && [ -f "$$src" ]; then \ + cp "$$src" "$$dest" && chmod +x "$$dest" && echo "bundled go-search-replace -> $$dest (from $$src)"; \ + elif [ -n "$$VIP_SEARCH_REPLACE_BIN" ]; then \ + echo "no bundled go-search-replace for $$os/$$arch; using VIP_SEARCH_REPLACE_BIN=$$VIP_SEARCH_REPLACE_BIN"; \ + elif [ -n "$$ALLOW_MISSING_SEARCH_REPLACE" ]; then \ + echo "WARNING: no go-search-replace for $$os/$$arch; search-replace, import sql --search-replace and dev-env sync sql will fail at runtime (ALLOW_MISSING_SEARCH_REPLACE set)"; \ + else \ + if [ -z "$$f" ]; then \ + echo "ERROR: no bundled go-search-replace for $$os/$$arch." >&2; \ + else \ + echo "ERROR: bundled go-search-replace fixture is missing: $$src" >&2; \ + fi; \ + echo "" >&2; \ + echo " vip-next would build, then fail at runtime on: search-replace," >&2; \ + echo " import sql --search-replace, dev-env sync sql." >&2; \ + echo "" >&2; \ + echo " Fix one of:" >&2; \ + echo " make vendor-search-replace # fetch + verify from upstream" >&2; \ + echo " VIP_SEARCH_REPLACE_BIN=/path/to/go-search-replace make build" >&2; \ + echo " ALLOW_MISSING_SEARCH_REPLACE=1 make build # build without it" >&2; \ + echo "" >&2; \ + echo " Pinned release: $(GSR_DIR)/MANIFEST" >&2; \ + exit 1; \ + fi + +# Proxy variables are scrubbed for the same reason internal/parity's +# scenarioEnvPinned and BuildParkerEnv scrub them: internal/httpproxy honours +# VIP_PROXY unconditionally and applies no loopback exemption (neither does +# Node's proxy-from-env), so a developer with the VIP SOCKS proxy exported would +# have every httptest server in the suite dialled through it. NO_PROXY is +# cleared too — with it set, the ported coveredInNoProxy suppresses a +# SOCKS_PROXY-only configuration, which would mask a real regression. +# `test-parity-unit-hostile` deliberately does the opposite and is a separate +# target; do not scrub there. +PROXY_SCRUB = VIP_PROXY= vip_proxy= SOCKS_PROXY= socks_proxy= \ + HTTPS_PROXY= https_proxy= HTTP_PROXY= http_proxy= \ + ALL_PROXY= all_proxy= NO_PROXY= no_proxy= VIP_USE_SYSTEM_PROXY= + +# The Go package list, discovered rather than hardcoded so a new top-level +# tree is picked up automatically, with node_modules removed. +# +# node_modules matters because CI now runs `npm ci` — the parity job diffs +# vip-next against the built Node CLI. An npm dependency ships real Go source, +# node_modules/flatted/golang/pkg/flatted, which lands inside this module, so a +# bare `./...` compiles and vets third-party Go pulled from the npm registry. +# (Verified on Go 1.27: `go list ./...` does include it.) +# +# The empty guard is not paranoia: `go test` with no package arguments tests +# the current directory and exits 0. A silently-empty list would look exactly +# like a passing suite, which is the failure mode this whole area exists to +# remove. +# +# -buildvcs=false keeps `go list` working when the checkout sits under another +# VCS's working copy. +GO_PKG_LIST = pkgs="$$($(GO) list -buildvcs=false ./... | grep -v '/node_modules/')"; \ + [ -n "$$pkgs" ] || { echo 'go list produced no packages; refusing to report success' >&2; exit 1; } + +test: + @$(GO_PKG_LIST); \ + $(PROXY_SCRUB) $(GO) test $$pkgs + +# The three host checks that decide whether the Node CLI can be executed. +# Shared verbatim by the warn-only and the fail-hard targets below so the two +# can never disagree about what "ready" means. Mirrors ResolveNodeVipBin in +# internal/parity/nodebin.go (see the comment on LoudSkip for why the check is +# duplicated in shell at all). +define NODE_VIP_BIN_PROBE +missing=''; \ +[ -f "$(NODE_VIP_BIN)" ] || missing="$$missing\n - $(NODE_VIP_BIN) does not exist; run 'npm run build'"; \ +[ -d "$(CURDIR)/node_modules" ] || missing="$$missing\n - $(CURDIR)/node_modules is absent; run 'npm ci'"; \ +command -v node >/dev/null 2>&1 || missing="$$missing\n - 'node' is not on PATH; install Node 22.19+ (package.json engines)"; +endef + +# Reports whether the Node CLI can be executed, and if not, exactly what is +# missing. `go test` buffers a passing package's output, so a t.Skip inside the +# suite is invisible without -v — this banner is what keeps a skipped +# differential scenario from looking like a passing one. +# +# This target WARNS and succeeds: a contributor who has never run `npm ci` must +# still be able to run `make test-parity-unit`. CI calls require-node-vip-bin +# instead, which fails. +node-vip-bin-status: + @$(NODE_VIP_BIN_PROBE) \ + if [ -z "$$missing" ]; then \ + echo "parity: Node-vs-Go differential coverage ON (NODE_VIP_BIN=$(NODE_VIP_BIN))"; \ + else \ + printf '\n%s\n' "================================================================================"; \ + printf ' WARNING: Node-vs-Go differential coverage is OFF.\n'; \ + printf ' The Node-vs-Go differential scenarios are the ONLY tests that run the real\n'; \ + printf ' Node CLI; they will SKIP. Every other scenario compares vip-next against a\n'; \ + printf ' mock.\n'; \ + printf ' Missing:%b\n' "$$missing"; \ + printf '%s\n\n' "================================================================================"; \ + fi + +# The CI counterpart of node-vip-bin-status: same probe, non-zero exit. +# +# Without this, the failure mode that made the differential worthless is +# invisible and permanent — if dist/ stops being built, every Node-vs-Go +# scenario silently skips and the job still goes green. A skipped differential +# must never be indistinguishable from a passing one in CI. +require-node-vip-bin: + @$(NODE_VIP_BIN_PROBE) \ + if [ -n "$$missing" ]; then \ + printf '\n%s\n' "================================================================================"; \ + printf ' ERROR: the Node CLI cannot be executed, so every Node-vs-Go differential\n'; \ + printf ' scenario would SKIP. In CI that is a failure, not a degradation.\n'; \ + printf ' Missing:%b\n' "$$missing"; \ + printf '%s\n\n' "================================================================================"; \ + exit 1; \ + fi; \ + echo "parity: Node-vs-Go differential coverage ON (NODE_VIP_BIN=$(NODE_VIP_BIN))" + +# Lists the scenarios whose Node-vs-Go divergence has been accepted as +# intentional, straight from the YAML that records the decision. +# +# It exists for the same reason node-vip-bin-status does: `go test` without -v +# discards a PASSING package's output, so the banner the differential writes +# when it meets a blessed divergence is invisible in a green run. A divergence +# nobody ever sees is indistinguishable from parity, and this list is the thing +# a reviewer should be arguing with. +.PHONY: blessed-drift-status +blessed-drift-status: + @names=$$(grep -l '^expected_drift:' testdata/parity/*.yaml 2>/dev/null | \ + sed 's|testdata/parity/||; s|\.yaml$$||' | sort); \ + if [ -n "$$names" ]; then \ + printf 'parity: %s scenario(s) carry an accepted Node-vs-Go divergence:\n' "$$(echo "$$names" | wc -l | tr -d ' ')"; \ + echo "$$names" | sed 's/^/ - /'; \ + printf ' Each records its reason and normalized-output signature in testdata/parity/.yaml (expected_drift).\n'; \ + fi + +# -count=1 disables the test cache: these scenarios spawn the built binaries +# and read the environment, so a cached "ok" would hide exactly the ambient +# dependence this suite is meant to detect. +test-parity-unit: node-vip-bin-status blessed-drift-status + NODE_VIP_BIN="$(NODE_VIP_BIN)" \ + $(GO) test -tags=parity -count=1 ./internal/parity/... + +# Proof that the fixture suite is ambient-independent (see internal/parity/env.go). +# Exports credentials, an API host, and proxies that would break or falsely +# satisfy scenarios if any of them leaked into a subprocess; results MUST be +# identical to `make test-parity-unit`. Run both after touching the harness. +# +# NODE_VIP_BIN is passed through deliberately: the Node-vs-Go scenario must +# stay ambient-independent too. +test-parity-unit-hostile: + VIP_TOKEN_OVERRIDE=hostile.ambient.token \ + WPVIP_DEPLOY_TOKEN=hostile-ambient-deploy-token \ + API_HOST=https://hostile.invalid \ + HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 ALL_PROXY=socks5://127.0.0.1:9 \ + http_proxy=http://127.0.0.1:9 https_proxy=http://127.0.0.1:9 all_proxy=socks5://127.0.0.1:9 \ + VIP_PROXY=socks5://127.0.0.1:9 SOCKS_PROXY=socks5://127.0.0.1:9 VIP_USE_SYSTEM_PROXY=1 \ + NODE_ENV=production DO_NOT_TRACK=0 NO_COLOR=1 DEBUG='*' \ + XDG_DATA_HOME=/nonexistent/vip-parity-hostile \ + VIP_SEARCH_REPLACE_BIN=/nonexistent/go-search-replace \ + $(MAKE) --no-print-directory test-parity-unit + +test-parity: + npm run build + @$(MAKE) --no-print-directory build + NODE_VIP_BIN="$(NODE_VIP_BIN)" \ + GO_VIP_BIN=$(CURDIR)/bin/vip-next \ + $(GO) test -tags='parity parker_parity' ./internal/parity \ + -run '^TestLocalParkerParity$$' -count=1 -v + +lint: + @$(GO_PKG_LIST); \ + $(GO) vet $$pkgs + +tidy: + $(GO) mod tidy + +clean: + rm -rf $(BIN_DIR) + +# Regenerate internal/gql/generated.go from schema.gql + operations/*.graphql. +tidy-gql: + cd internal/gql && $(GO) run github.com/Khan/genqlient + +# Fail if internal/gql/generated.go on disk doesn't match what genqlient +# would produce from schema.gql + operations/*.graphql. The recipe never +# leaves the on-disk file altered: it stashes the contributor's copy to a +# temp file, runs genqlient (which writes to the configured generated.go), +# compares, and ALWAYS restores the stashed copy via a shell trap -- so +# even on errors or interrupts the working tree is left exactly as the +# contributor had it. (genqlient v0.8.1 does not support --output, so we +# can't redirect codegen directly; the trap-based restore is reliable +# because it always uses the saved file, unlike the prior recipe which +# restored from the post-regen file.) +verify-gql-stale: + @cd internal/gql && \ + stash=$$(mktemp) && fresh=$$(mktemp) && \ + trap 'mv -f "$$stash" generated.go 2>/dev/null; rm -f "$$fresh"' EXIT INT TERM HUP; \ + cp generated.go "$$stash" && \ + $(GO) run github.com/Khan/genqlient && \ + cp generated.go "$$fresh" && \ + if cmp -s "$$stash" "$$fresh"; then \ + echo "internal/gql/generated.go is up to date"; \ + else \ + echo ""; \ + echo "ERROR: internal/gql/generated.go is stale relative to schema.gql / operations/*.graphql."; \ + echo "Run 'make tidy-gql' and commit the regenerated file."; \ + exit 1; \ + fi diff --git a/cmd/vip-next/auth_bootstrap.go b/cmd/vip-next/auth_bootstrap.go new file mode 100644 index 000000000..fd63d9715 --- /dev/null +++ b/cmd/vip-next/auth_bootstrap.go @@ -0,0 +1,133 @@ +package main + +import ( + "errors" + "strconv" + "strings" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/telemetry" +) + +type authSession struct { + Raw string + Keychain *keychain.Keychain + Store *auth.Store +} + +type authBootstrapDeps struct { + Keychain *keychain.Keychain + Store *auth.Store + Login func() (*auth.Token, error) +} + +// withAuthenticatedSession resolves a valid stored token or, when interactive, +// runs login and resumes the original command with the freshly returned token. +func withAuthenticatedSession( + interactive bool, + deps authBootstrapDeps, + next func(*authSession) error, +) error { + raw, loadErr := deps.Store.Load() + if loadErr == nil { + tok, parseErr := auth.ParseToken(raw) + if parseErr == nil && tok.Valid() { + return next(&authSession{Raw: tok.Raw, Keychain: deps.Keychain, Store: deps.Store}) + } + } + if loadErr != nil && !errors.Is(loadErr, auth.ErrNoToken) { + return loadErr + } + if !interactive { + if errors.Is(loadErr, auth.ErrNoToken) { + return errors.New("not logged in: run `vip login` to obtain a token, then retry") + } + return errors.New("stored token is invalid or expired: run `vip login` to refresh") + } + + tok, err := deps.Login() + if errors.Is(err, auth.ErrLoginCancelled) || auth.IsHandledLoginError(err) { + return nil + } + if err != nil { + return err + } + if tok == nil || !tok.Valid() { + return errors.New("login completed without a valid token") + } + return next(&authSession{Raw: tok.Raw, Keychain: deps.Keychain, Store: deps.Store}) +} + +// isNonInteractiveArgv detects the root flag without mistaking a raw WP-CLI +// flag after `wp` (or an argument after `--`) for a vip-next flag. +func isNonInteractiveArgv(argv []string) bool { + nonInteractive := false + commandSeen := false + skipFlagValue := false + for _, arg := range argv { + if skipFlagValue { + skipFlagValue = false + continue + } + if arg == "--" { + return nonInteractive + } + // app and env are the only root flags whose value may be a separate + // token. Do not mistake an app named "wp" for the raw wp command. + if arg == "--app" || arg == "--env" { + skipFlagValue = true + continue + } + if arg == "--non-interactive" { + nonInteractive = true + continue + } + if strings.HasPrefix(arg, "--non-interactive=") { + value := strings.TrimPrefix(arg, "--non-interactive=") + parsed, err := strconv.ParseBool(value) + // A malformed value will later be rejected by cobra. Treat it as + // non-interactive here so the bootstrap cannot open a browser first. + nonInteractive = err != nil || parsed + continue + } + if commandSeen || strings.HasPrefix(arg, "-") || strings.HasPrefix(arg, "@") { + continue + } + commandSeen = true + if arg == "wp" { + // Everything following the root wp command belongs to WP-CLI. + return nonInteractive + } + } + return nonInteractive +} + +type runDeps struct { + Tracker *telemetry.Tracker + NewKeychain func(apiHost string) *keychain.Keychain + NewLogin func(store *auth.Store) func() (*auth.Token, error) +} + +type telemetryLoginTracker struct { + Tracker *telemetry.Tracker +} + +func (a telemetryLoginTracker) Track(name string, props map[string]any) { + a.Tracker.TrackEvent(name, props) +} + +func productionRunDeps(tracker *telemetry.Tracker) runDeps { + return runDeps{ + Tracker: tracker, + NewKeychain: keychain.New, + NewLogin: func(store *auth.Store) func() (*auth.Token, error) { + flow := auth.NewProductionLoginFlow( + store, + telemetryLoginTracker{Tracker: tracker}, + tracker.AliasUser, + ) + return flow.Run + }, + } +} diff --git a/cmd/vip-next/auth_bootstrap_test.go b/cmd/vip-next/auth_bootstrap_test.go new file mode 100644 index 000000000..ae236f2ba --- /dev/null +++ b/cmd/vip-next/auth_bootstrap_test.go @@ -0,0 +1,379 @@ +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "testing" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" +) + +type bootstrapBackend struct { + secrets map[string]string + getErr error +} + +func (b *bootstrapBackend) Set(service, user, secret string) error { + if b.secrets == nil { + b.secrets = make(map[string]string) + } + b.secrets[service+"\x00"+user] = secret + return nil +} + +func (b *bootstrapBackend) Get(service, user string) (string, error) { + if b.getErr != nil { + return "", b.getErr + } + secret, ok := b.secrets[service+"\x00"+user] + if !ok { + return "", keychain.ErrNotFound + } + return secret, nil +} + +func (b *bootstrapBackend) Delete(service, user string) error { + key := service + "\x00" + user + if _, ok := b.secrets[key]; !ok { + return keychain.ErrNotFound + } + delete(b.secrets, key) + return nil +} + +func newBootstrapKeychain(backend *bootstrapBackend) *keychain.Keychain { + return &keychain.Keychain{ + Backend: backend, + Service: "vip-next-bootstrap-test", + LegacyService: "vip-go-cli-bootstrap-test", + } +} + +func validBootstrapRaw(t *testing.T, id int64) string { + t.Helper() + header, err := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + if err != nil { + t.Fatalf("marshal JWT header: %v", err) + } + claims, err := json.Marshal(map[string]any{ + "id": id, + "iat": time.Now().Add(-time.Minute).Unix(), + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatalf("marshal JWT claims: %v", err) + } + enc := base64.RawURLEncoding + return enc.EncodeToString(header) + "." + enc.EncodeToString(claims) + "." +} + +func parsedBootstrapToken(t *testing.T, raw string) *auth.Token { + t.Helper() + tok, err := auth.ParseToken(raw) + if err != nil { + t.Fatalf("parse token: %v", err) + } + return tok +} + +func TestWithAuthenticatedSessionUsesValidStoredToken(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &bootstrapBackend{} + k := newBootstrapKeychain(backend) + store := auth.NewStore(k) + raw := validBootstrapRaw(t, 10000) + if err := store.Save(raw); err != nil { + t.Fatalf("save token: %v", err) + } + loginCalls := 0 + nextCalls := 0 + + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: store, + Login: func() (*auth.Token, error) { + loginCalls++ + return nil, errors.New("login should not run") + }, + }, func(session *authSession) error { + nextCalls++ + if session.Raw != raw || session.Keychain != k || session.Store != store { + t.Fatalf("session = %#v, want stored-token dependencies", session) + } + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if loginCalls != 0 || nextCalls != 1 { + t.Fatalf("login calls = %d, continuation calls = %d; want 0, 1", loginCalls, nextCalls) + } +} + +func TestWithAuthenticatedSessionUsesLegacyTokenWhenPrimaryMissing(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &bootstrapBackend{} + k := newBootstrapKeychain(backend) + raw := validBootstrapRaw(t, 10000) + if err := backend.Set(k.LegacyService, k.LegacyService, raw); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + loginCalls := 0 + + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { + loginCalls++ + return nil, errors.New("login should not run") + }, + }, func(session *authSession) error { + if session.Raw != raw { + t.Fatalf("session token = %q, want legacy token", session.Raw) + } + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", loginCalls) + } +} + +func TestWithAuthenticatedSessionLogsInWhenMissingAndResumes(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &bootstrapBackend{} + k := newBootstrapKeychain(backend) + store := auth.NewStore(k) + freshRaw := validBootstrapRaw(t, 10000) + loginCalls := 0 + nextCalls := 0 + + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: store, + Login: func() (*auth.Token, error) { + loginCalls++ + return parsedBootstrapToken(t, freshRaw), nil + }, + }, func(session *authSession) error { + nextCalls++ + if session.Raw != freshRaw { + t.Fatalf("continuation token = %q, want freshly returned token", session.Raw) + } + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if loginCalls != 1 || nextCalls != 1 { + t.Fatalf("login calls = %d, continuation calls = %d; want 1, 1", loginCalls, nextCalls) + } +} + +func TestWithAuthenticatedSessionRefreshesInvalidTokenAndResumes(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &bootstrapBackend{} + k := newBootstrapKeychain(backend) + store := auth.NewStore(k) + if err := store.Save("invalid-stored-token"); err != nil { + t.Fatalf("save invalid token: %v", err) + } + freshRaw := validBootstrapRaw(t, 10000) + loginCalls := 0 + nextCalls := 0 + + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: store, + Login: func() (*auth.Token, error) { + loginCalls++ + return parsedBootstrapToken(t, freshRaw), nil + }, + }, func(session *authSession) error { + nextCalls++ + if session.Raw != freshRaw { + t.Fatalf("continuation token = %q, want freshly returned token", session.Raw) + } + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if loginCalls != 1 || nextCalls != 1 { + t.Fatalf("login calls = %d, continuation calls = %d; want 1, 1", loginCalls, nextCalls) + } +} + +func TestWithAuthenticatedSessionStopsCleanlyOnCancel(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + k := newBootstrapKeychain(&bootstrapBackend{}) + nextCalls := 0 + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { return nil, auth.ErrLoginCancelled }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if nextCalls != 0 { + t.Fatalf("continuation calls = %d, want 0", nextCalls) + } +} + +func TestWithAuthenticatedSessionStopsCleanlyOnHandledValidationError(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + k := newBootstrapKeychain(&bootstrapBackend{}) + nextCalls := 0 + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { return nil, fmt.Errorf("wrapped: %w", auth.ErrTokenInvalid) }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if err != nil { + t.Fatalf("withAuthenticatedSession: %v", err) + } + if nextCalls != 0 { + t.Fatalf("continuation calls = %d, want 0", nextCalls) + } +} + +func TestWithAuthenticatedSessionSurfacesUnexpectedLoginError(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + k := newBootstrapKeychain(&bootstrapBackend{}) + want := errors.New("browser exploded") + nextCalls := 0 + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { return nil, want }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } + if nextCalls != 0 { + t.Fatalf("continuation calls = %d, want 0", nextCalls) + } +} + +func TestWithAuthenticatedSessionNonInteractiveMissingFailsWithoutLogin(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + k := newBootstrapKeychain(&bootstrapBackend{}) + loginCalls := 0 + nextCalls := 0 + err := withAuthenticatedSession(false, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { + loginCalls++ + return nil, nil + }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if err == nil || !strings.Contains(err.Error(), "not logged in") { + t.Fatalf("error = %v, want not-logged-in error", err) + } + if loginCalls != 0 || nextCalls != 0 { + t.Fatalf("login calls = %d, continuation calls = %d; want 0, 0", loginCalls, nextCalls) + } +} + +func TestWithAuthenticatedSessionNonInteractiveInvalidFailsWithoutLogin(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &bootstrapBackend{} + k := newBootstrapKeychain(backend) + store := auth.NewStore(k) + if err := store.Save("invalid-stored-token"); err != nil { + t.Fatalf("save invalid token: %v", err) + } + loginCalls := 0 + nextCalls := 0 + err := withAuthenticatedSession(false, authBootstrapDeps{ + Keychain: k, + Store: store, + Login: func() (*auth.Token, error) { + loginCalls++ + return nil, nil + }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if err == nil || !strings.Contains(err.Error(), "invalid or expired") { + t.Fatalf("error = %v, want invalid-token error", err) + } + if loginCalls != 0 || nextCalls != 0 { + t.Fatalf("login calls = %d, continuation calls = %d; want 0, 0", loginCalls, nextCalls) + } +} + +func TestWithAuthenticatedSessionSurfacesKeychainLoadError(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + want := errors.New("keychain unavailable") + k := newBootstrapKeychain(&bootstrapBackend{getErr: want}) + loginCalls := 0 + nextCalls := 0 + err := withAuthenticatedSession(true, authBootstrapDeps{ + Keychain: k, + Store: auth.NewStore(k), + Login: func() (*auth.Token, error) { + loginCalls++ + return nil, nil + }, + }, func(*authSession) error { + nextCalls++ + return nil + }) + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } + if loginCalls != 0 || nextCalls != 0 { + t.Fatalf("login calls = %d, continuation calls = %d; want 0, 0", loginCalls, nextCalls) + } +} + +func TestIsNonInteractiveArgvBoundaries(t *testing.T) { + tests := []struct { + argv []string + want bool + }{ + {[]string{"--non-interactive", "app", "list"}, true}, + {[]string{"app", "list", "--non-interactive"}, true}, + {[]string{"--non-interactive=true", "whoami"}, true}, + {[]string{"--non-interactive=false", "whoami"}, false}, + {[]string{"--", "--non-interactive"}, false}, + {[]string{"wp", "option", "get", "home", "--non-interactive"}, false}, + {[]string{"@app.env", "wp", "--non-interactive"}, false}, + {[]string{"--non-interactive", "@app.env", "wp", "option", "get", "home"}, true}, + {[]string{"--app", "wp", "app", "list", "--non-interactive"}, true}, + {[]string{"app", "list", "--env", "wp", "--non-interactive"}, true}, + {[]string{"app", "list", "--non-interactive=false", "--non-interactive"}, true}, + {[]string{"--non-interactive=invalid", "whoami"}, true}, + } + for _, tt := range tests { + t.Run(strings.Join(tt.argv, " "), func(t *testing.T) { + if got := isNonInteractiveArgv(tt.argv); got != tt.want { + t.Fatalf("isNonInteractiveArgv(%q) = %v, want %v", tt.argv, got, tt.want) + } + }) + } +} diff --git a/cmd/vip-next/auth_bypass_wiring_test.go b/cmd/vip-next/auth_bypass_wiring_test.go new file mode 100644 index 000000000..c3231ae8a --- /dev/null +++ b/cmd/vip-next/auth_bypass_wiring_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/telemetry" +) + +// gqlOpRecorder is a GraphQL stub that dispatches on the operationName in the +// request body and records which operations were asked for. It is deliberately +// dumb: the responses are the minimum genqlient will unmarshal. +type gqlOpRecorder struct { + mu sync.Mutex + ops []string + auth []string +} + +func (r *gqlOpRecorder) server(t *testing.T, bodies map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + raw, _ := io.ReadAll(req.Body) + op := "" + for name := range bodies { + if strings.Contains(string(raw), `"operationName":"`+name+`"`) { + op = name + break + } + } + r.mu.Lock() + r.ops = append(r.ops, op) + r.auth = append(r.auth, req.Header.Get("Authorization")) + r.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if op == "" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"errors":[{"message":"unexpected operation: ` + string(raw) + `"}]}`)) + return + } + _, _ = w.Write([]byte(bodies[op])) + })) +} + +func (r *gqlOpRecorder) saw(op string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, seen := range r.ops { + if seen == op { + return true + } + } + return false +} + +const ( + resolveAppByNameBody = `{"data":{"apps":{"edges":[{"id":1,"name":"example","type":"WordPress","typeId":2,` + + `"environments":[{"id":2,"appId":1,"name":"develop","type":"develop","uniqueLabel":"example-develop",` + + `"defaultDomain":"example-develop.go-vip.net","isMultisite":false}]}]}}}` + envVarsWithValuesBody = `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":` + + `{"total":1,"nodes":[{"name":"HELP","value":"not-a-bypass"}]}}]}}}` +) + +// TestBypassedArgvStillReachesTheAPI reproduces register item 2.13. +// +// Node's src/bin/vip.js decides only ONE thing with its argv scan: whether to +// run the interactive login flow. When it skips it, runCmd() still has full API +// access, because src/lib/api/http.ts loads the token from the keychain on every +// request. vip-next conflated "skip login" with "skip API setup": a bypassed +// argv got a commands.Config with no GQLClient, so any command whose argv merely +// CONTAINED a bypass word ("help", "login", "logout", "-v", ...) died with +// "appctx: GraphQL client not configured". +// +// `config envvar get help` is one of the three invocations the parity review +// verified as broken. A stored, valid token exists here, so Node would have run +// the command against the API — and so must vip-next. +func TestBypassedArgvStillReachesTheAPI(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("VIP_TOKEN_OVERRIDE", "") + t.Setenv("WPVIP_DEPLOY_TOKEN", "") + + rec := &gqlOpRecorder{} + srv := rec.server(t, map[string]string{ + "ResolveAppByName": resolveAppByNameBody, + "GetEnvironmentVariablesWithValues": envVarsWithValuesBody, + }) + defer srv.Close() + t.Setenv("API_HOST", srv.URL) + + raw := validBootstrapRaw(t, 10000) + backend := &bootstrapBackend{} + testKeychain := newBootstrapKeychain(backend) + if err := auth.NewStore(testKeychain).Save(raw); err != nil { + t.Fatalf("seed token: %v", err) + } + + loginCalls := 0 + err := runWithDeps( + []string{"config", "envvar", "get", "help", "--app", "example", "--env", "develop"}, + runDeps{ + Tracker: &telemetry.Tracker{Disabled: true}, + NewKeychain: func(string) *keychain.Keychain { return testKeychain }, + NewLogin: func(*auth.Store) func() (*auth.Token, error) { + return func() (*auth.Token, error) { + loginCalls++ + return nil, auth.ErrLoginCancelled + } + }, + }) + if err != nil { + t.Fatalf("`config envvar get help` must run like any other command; got %v", err) + } + if loginCalls != 0 { + t.Fatalf("login flow ran %d times; a valid stored token must never trigger it", loginCalls) + } + if !rec.saw("ResolveAppByName") { + t.Errorf("app was never resolved — the GraphQL client was not configured (ops=%v)", rec.ops) + } + if !rec.saw("GetEnvironmentVariablesWithValues") { + t.Errorf("the envvar query never ran (ops=%v)", rec.ops) + } + for _, got := range rec.auth { + if got != "Bearer "+raw { + t.Errorf("Authorization = %q, want the stored token", got) + } + } +} + +// TestHelpWithoutStoredTokenStillSkipsLogin pins the half of Node's rule that +// must NOT regress: --help with an empty keychain prints help and never opens a +// login prompt (vip.js:204-212, isHelpCommand short-circuits the login branch). +func TestHelpWithoutStoredTokenStillSkipsLogin(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("VIP_TOKEN_OVERRIDE", "") + t.Setenv("WPVIP_DEPLOY_TOKEN", "") + t.Setenv("API_HOST", "http://127.0.0.1:1") + + backend := &bootstrapBackend{} + testKeychain := newBootstrapKeychain(backend) + loginCalls := 0 + err := runWithDeps([]string{"--help"}, runDeps{ + Tracker: &telemetry.Tracker{Disabled: true}, + NewKeychain: func(string) *keychain.Keychain { return testKeychain }, + NewLogin: func(*auth.Store) func() (*auth.Token, error) { + return func() (*auth.Token, error) { + loginCalls++ + return nil, auth.ErrLoginCancelled + } + }, + }) + if err != nil { + t.Fatalf("--help must exit 0 without a token; got %v", err) + } + if loginCalls != 0 { + t.Fatalf("--help triggered the login flow %d times", loginCalls) + } +} diff --git a/cmd/vip-next/cli_error_hook_test.go b/cmd/vip-next/cli_error_hook_test.go new file mode 100644 index 000000000..7530eb4c5 --- /dev/null +++ b/cmd/vip-next/cli_error_hook_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/telemetry" +) + +type capturingClient struct { + events []capturedEvent +} + +type capturedEvent struct { + name string + props map[string]any +} + +func (c *capturingClient) TrackEvent(name string, props map[string]any) error { + c.events = append(c.events, capturedEvent{name: name, props: props}) + return nil +} + +// TestCLIErrorHookScrubsThePayload is the end-to-end assertion for the one +// telemetry event vip-next sends that Node has no counterpart for. +// +// exit.RegisterErrorHook fires on every non-zero exit, and the hook posts the +// error text to public-api.wordpress.com. Before this change it sent +// err.Error() verbatim, so an ordinary "open : permission denied" carried +// the user's home directory — account name and all — plus, on a failed +// presigned download, a live credential. +func TestCLIErrorHookScrubsThePayload(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + secretPath := filepath.Join(home, "clients", "acme-corp", "db.sql") + + client := &capturingClient{} + tracker := &telemetry.Tracker{Clients: []telemetry.Client{client}} + + hook := cliErrorHook(tracker) + hook(errors.New("open " + secretPath + ": permission denied")) + + if len(client.events) != 1 { + t.Fatalf("got %d events, want 1", len(client.events)) + } + ev := client.events[0] + if ev.name != "cli_error" { + t.Errorf("event name = %q, want cli_error", ev.name) + } + text, _ := ev.props["error"].(string) + if strings.Contains(text, home) { + t.Errorf("cli_error payload carries the home directory off-box:\n\t%s", text) + } + if !strings.Contains(text, "permission denied") { + t.Errorf("cli_error payload lost the diagnostic part:\n\t%s", text) + } +} + +// TestCLIErrorHookScrubsPresignedCredentials pins the credential case +// specifically: a failed media-import report download returns a *url.Error +// carrying the signature query string. +func TestCLIErrorHookScrubsPresignedCredentials(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + + client := &capturingClient{} + tracker := &telemetry.Tracker{Clients: []telemetry.Client{client}} + + cliErrorHook(tracker)(errors.New( + `Get "https://vip.s3.amazonaws.com/report.json?X-Amz-Signature=abc123def456": i/o timeout`)) + + if len(client.events) != 1 { + t.Fatalf("got %d events, want 1", len(client.events)) + } + text, _ := client.events[0].props["error"].(string) + for _, secret := range []string{"X-Amz-Signature", "abc123def456"} { + if strings.Contains(text, secret) { + t.Errorf("cli_error payload carries %q off-box:\n\t%s", secret, text) + } + } +} diff --git a/cmd/vip-next/commands/app.go b/cmd/vip-next/commands/app.go new file mode 100644 index 000000000..2fb270597 --- /dev/null +++ b/cmd/vip-next/commands/app.go @@ -0,0 +1,23 @@ +package commands + +import "github.com/spf13/cobra" + +// AppCmd returns the `vip app` parent command. Subcommands and the wildcard +// dispatcher are wired in main.go (so the wildcard sees the final subcommand +// list). +// +// --format is registered on the parent because `vip app ` dispatches via +// WithWildcardCommand — cobra parses flags against the matched command +// (appCmd), so the parent must own --format for RunAppGet to read it. +func AppCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "app", + Short: "Manage VIP Platform applications", + Long: "Manage VIP Platform applications.\n\n" + + "Run \"vip app list\" to list applications, or \"vip app \" " + + "to view information about a specific application and its environments.", + } + cmd.Flags().StringP("format", "f", "table", + "Render output in a particular format. Accepts \"table\" (default), \"csv\", \"json\".") + return cmd +} diff --git a/cmd/vip-next/commands/app_deploy.go b/cmd/vip-next/commands/app_deploy.go new file mode 100644 index 000000000..032d1638f --- /dev/null +++ b/cmd/vip-next/commands/app_deploy.go @@ -0,0 +1,213 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/customdeploy" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/tui" + "github.com/Automattic/vip/internal/upload" +) + +// AppDeployCmd returns `vip app deploy `. +// +// Node parity: src/bin/vip-app-deploy.ts. Custom Deployment authenticates +// with WPVIP_DEPLOY_TOKEN — never the keychain token — for both the +// access-validation and start-deploy mutations (custom-deploy.ts:56, +// vip-app-deploy.ts:216). No appctx resolution: --app/--env raw values +// go straight into ValidateCustomDeployAccess. +func AppDeployCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy ", + Short: "Deploy a local archived file to an environment with Custom Deployment enabled", + Long: "Deploy a local archived file (.zip, .tar.gz, .tgz) that contains application code to a " + + "VIP Platform environment that has Custom Deployment enabled. Requires WPVIP_DEPLOY_TOKEN.", + Args: cobra.ExactArgs(1), + RunE: runAppDeploy, + } + // src/bin/vip-app-deploy.ts registers message/skip-confirmation/force/app/env + // in that order; the shorts follow from createOptionDefinition. + cmd.Flags().StringP("message", "m", "", "Add a description of a deployment.") + cmd.Flags().BoolP("skip-confirmation", "s", false, "Skip the confirmation prompt.") + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt (deprecated)") + addAppEnvFlags(cmd) + return cmd +} + +// deployGQLClient builds a genqlient client that authenticates with the +// deploy token instead of the keychain token, reusing the standard +// middleware chain. +func deployGQLClient(cfg Config, deployToken string) graphql.Client { + httpClient := gql.HTTPClientWithMiddleware(cfg.APIHost, deployToken, cfg.Middleware) + return graphql.NewClient(cfg.APIHost+"/graphql", httpClient) +} + +// validateCustomDeployKey ports validateCustomDeployKey +// (custom-deploy.ts:28). +func validateCustomDeployKey(ctx context.Context, client graphql.Client, app, env string) (*customdeploy.DeployInfo, error) { + resp, err := gql.ValidateCustomDeployAccess(gql.WithAllowGQLErrors(ctx), client, + &gql.ValidateCustomDeployAccessInput{App: app, Env: env}) + if err != nil || resp == nil || resp.ValidateCustomDeployAccess == nil { + return nil, errors.New("Unauthorized: Invalid or non-existent custom deploy key for environment.") + } + v := resp.ValidateCustomDeployAccess + info := &customdeploy.DeployInfo{} + if v.AppId != nil { + info.AppID = *v.AppId + } + if v.EnvId != nil { + info.EnvID = *v.EnvId + } + if v.EnvType != nil { + info.EnvType = *v.EnvType + } + if v.EnvUniqueLabel != nil { + info.EnvUniqueLabel = *v.EnvUniqueLabel + } + if v.PrimaryDomainName != nil { + info.PrimaryDomainName = *v.PrimaryDomainName + } + if v.Launched != nil { + info.Launched = *v.Launched + } + return info, nil +} + +func runAppDeploy(cmd *cobra.Command, args []string) error { + cfg := GetConfig() + out := cmd.OutOrStdout() + fileName := args[0] + + message, _ := cmd.Flags().GetString("message") + skipConfirmation, _ := cmd.Flags().GetBool("skip-confirmation") + force, _ := cmd.Flags().GetBool("force") + skipConfirm := skipConfirmation || force + + meta, err := upload.GetFileMeta(fileName) + if err != nil { + return fmt.Errorf("Unable to access file %s", fileName) + } + + deployToken := os.Getenv("WPVIP_DEPLOY_TOKEN") + if deployToken == "" { + // custom-deploy.ts:33. + return errors.New("Valid custom deploy key is required.") + } + + appFlag := lookupRootFlag(cmd, "app") + envFlag := lookupRootFlag(cmd, "env") + + client := deployGQLClient(cfg, deployToken) + info, err := validateCustomDeployKey(cmd.Context(), client, appFlag, envFlag) + if err != nil { + return err + } + + if err := customdeploy.ValidateFile(meta, 0); err != nil { + trackEvent("deploy_app_command_error", map[string]any{"error_type": "invalid-file"}) + return err + } + + trackEvent("deploy_app_command_execute", nil) + + // Date-prefix the basename to avoid overwriting same-named files + // (vip-app-deploy.ts:101-106). + datePrefix := time.Now().UTC().Format("20060102150405") + meta.BaseName = datePrefix + "-" + meta.BaseName + + if !skipConfirm { + launchedLabel := "un-launched" + if info.Launched { + launchedLabel = "launched" + } + promptToMatch := strings.ToUpper(info.PrimaryDomainName) + // vip-app-deploy.ts:66 — note "site" wording and "un-launched" + // (hyphenated, unlike import sql's "unlaunched"). + promptMsg := fmt.Sprintf("You are about to deploy to a %s %s site %s.\nType '%s' (without the quotes) to continue:\n", + launchedLabel, formatEnvironment(info.EnvType), + color.YellowString(info.PrimaryDomainName), color.YellowString(promptToMatch)) + answer, perr := importInputPrompt(cmd, promptMsg, "") + if perr != nil || strings.ToUpper(answer) != promptToMatch { + trackEvent("deploy_app_unexpected_input", nil) + return errors.New("The input did not match the expected environment label. Deploy aborted.") + } + } + + // ===== progress phase; no stray prints below (js:122 WARNING) ===== + pt := tui.NewProgressTracker([]tui.ProgressStep{ + {ID: "upload", Name: "Uploading file"}, + {ID: "deploy", Name: "Triggering deployment"}, + }) + pt.SetPrefix("\n=============================================================\nProcessing the file for deployment to your environment...\n") + pt.SetSuffix("\n" + tui.GlyphForStatus(tui.StepRunning, tui.SpinnerGlyphs[0]) + " Running...") + renderer := startImportProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + failWithError := func(failureErr error) error { + pt.SetSuffix("\n" + tui.GlyphForStatus(tui.StepFailed, tui.SpinnerGlyphs[0]) + " Running...") + renderer.stop(cmd, true) + return failureErr + } + + _ = pt.StepRunning("upload") + uc := &upload.Client{APIHost: cfg.APIHost, Token: deployToken} + res, err := uc.UploadImportFile(cmd.Context(), info.AppID, info.EnvID, meta, "sha256", + func(pct string) { pt.SetUploadPercentage(pct) }) + if err != nil { + trackEvent("deploy_app_command_error", map[string]any{ + "error_type": "upload_failed", "upload_error": err.Error(), + }) + _ = pt.StepFailed("upload") + return failWithError(err) + } + _ = pt.StepSuccess("upload") + trackEvent("deploy_app_upload_complete", nil) + + // StartCustomDeploy uses the date-prefixed basename, NOT the + // (possibly .gz-renamed) upload basename — Node passes + // fileMeta.basename as captured before upload (vip-app-deploy.ts:187). + basename := meta.BaseName + checksum := res.Checksum + input := &gql.AppEnvironmentCustomDeployInput{ + Id: &info.AppID, + EnvironmentId: &info.EnvID, + Basename: &basename, + Checksum: &checksum, + DeployMessage: &message, + } + if _, err := gql.StartCustomDeploy(gql.WithAllowGQLErrors(cmd.Context()), client, input); err != nil { + trackEvent("deploy_app_command_error", map[string]any{"error_type": "StartDeploy-failed"}) + _ = pt.StepFailed("deploy") + return failWithError(fmt.Errorf("StartDeploy call failed: %s", err.Error())) + } + _ = pt.StepSuccess("deploy") + pt.SetSuffix("") + renderer.stop(cmd, true) + + // Final success block (vip-app-deploy.ts:240-249). + deploymentsURL := fmt.Sprintf("https://dashboard.wpvip.com/apps/%d/%s/code/deployments", info.AppID, info.EnvUniqueLabel) + fmt.Fprintf(out, "\n✅ %s has been sent for deployment to %s. \nTo check deployment status, go to %s: %s\n", + color.New(color.Bold, color.Underline, color.FgMagenta).Sprint(meta.BaseName), + color.New(color.Bold, color.FgBlue).Sprint(info.PrimaryDomainName), + color.New(color.Bold).Sprint("VIP Dashboard"), + deploymentsURL) + return nil +} + +// lookupRootFlag reads the root-level persistent --app/--env values the +// deploy command consumes raw (Node: opts.app/opts.env). +func lookupRootFlag(cmd *cobra.Command, name string) string { + if f := cmd.Flag(name); f != nil { + return f.Value.String() + } + return "" +} diff --git a/cmd/vip-next/commands/app_deploy_test.go b/cmd/vip-next/commands/app_deploy_test.go new file mode 100644 index 000000000..bc7a45139 --- /dev/null +++ b/cmd/vip-next/commands/app_deploy_test.go @@ -0,0 +1,297 @@ +package commands + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// deployStub serves ValidateCustomDeployAccess + StartCustomDeploy + +// presign/S3. Captures the Authorization headers per operation. +type deployStub struct { + mu sync.Mutex + validateAuth string + startAuth string + startReq string + uploadedBody []byte + validateFails bool + srvURL string +} + +func (s *deployStub) start(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + s.srvURL = srv.URL + + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ValidateCustomDeployAccess"`): + s.mu.Lock() + s.validateAuth = r.Header.Get("Authorization") + fails := s.validateFails + s.mu.Unlock() + if fails { + _, _ = w.Write([]byte(`{"data":null,"errors":[{"message":"Not found"}]}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"validateCustomDeployAccess":{"success":true,"appId":42,"envId":7, + "envType":"develop","envUniqueLabel":"develop","primaryDomainName":"example.com","launched":false}}}`)) + case strings.Contains(bs, `"operationName":"StartCustomDeploy"`): + s.mu.Lock() + s.startAuth = r.Header.Get("Authorization") + s.startReq = bs + s.mu.Unlock() + _, _ = w.Write([]byte(`{"data":{"startCustomDeploy":{"success":true,"message":"queued"}}}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"url":"%s/s3target","options":{"method":"PUT","headers":{}}}`, s.srvURL) + }) + mux.HandleFunc("/s3target", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.uploadedBody = body + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + return srv +} + +// deployArchive builds a minimal valid .tar.gz (root dir + themes/). +func deployArchive(t *testing.T, name string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + tw := tar.NewWriter(zw) + for _, d := range []string{"app/", "app/themes/"} { + if err := tw.WriteHeader(&tar.Header{Name: d, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + } + if err := tw.WriteHeader(&tar.Header{Name: "app/themes/style.css", Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte("x")) + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func newDeployCmd(stub *deployStub, t *testing.T) *bytes.Buffer { + t.Helper() + srv := stub.start(t) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, Token: "keychain-token", + }) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") + return &bytes.Buffer{} +} + +func TestAppDeployMissingToken(t *testing.T) { + stub := &deployStub{} + _ = newDeployCmd(stub, t) + t.Setenv("WPVIP_DEPLOY_TOKEN", "") + + cmd := AppDeployCmd() + cmd.SetContext(context.Background()) + // AppDeployCmd now registers -a/--app and -e/--env itself (Node parity); + // the test only needs to set them. + _ = cmd.Flags().Set("app", "myapp") + _ = cmd.Flags().Set("env", "develop") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := runAppDeploy(cmd, []string{deployArchive(t, "rel.tar.gz")}) + if err == nil || err.Error() != "Valid custom deploy key is required." { + t.Errorf("err = %v", err) + } +} + +func TestAppDeployInvalidKey(t *testing.T) { + stub := &deployStub{validateFails: true} + _ = newDeployCmd(stub, t) + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + + cmd := AppDeployCmd() + cmd.SetContext(context.Background()) + // AppDeployCmd now registers -a/--app and -e/--env itself (Node parity); + // the test only needs to set them. + _ = cmd.Flags().Set("app", "myapp") + _ = cmd.Flags().Set("env", "develop") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := runAppDeploy(cmd, []string{deployArchive(t, "rel.tar.gz")}) + if err == nil || err.Error() != "Unauthorized: Invalid or non-existent custom deploy key for environment." { + t.Errorf("err = %v", err) + } +} + +func TestAppDeployHappyPath(t *testing.T) { + stub := &deployStub{} + out := newDeployCmd(stub, t) + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + + cmd := AppDeployCmd() + cmd.SetContext(context.Background()) + // AppDeployCmd now registers -a/--app and -e/--env itself (Node parity); + // the test only needs to set them. + _ = cmd.Flags().Set("app", "myapp") + _ = cmd.Flags().Set("env", "develop") + cmd.SetOut(out) + cmd.SetErr(io.Discard) + _ = cmd.Flags().Set("skip-confirmation", "true") + _ = cmd.Flags().Set("message", "release notes") + + archive := deployArchive(t, "rel.tar.gz") + if err := runAppDeploy(cmd, []string{archive}); err != nil { + t.Fatalf("runAppDeploy: %v\nout: %s", err, out.String()) + } + + stub.mu.Lock() + defer stub.mu.Unlock() + // Both mutations must carry the deploy token, not the keychain token. + if stub.validateAuth != "Bearer deploy-tok" || stub.startAuth != "Bearer deploy-tok" { + t.Errorf("auth: validate=%q start=%q", stub.validateAuth, stub.startAuth) + } + // Basename is date-prefixed (14 digits + dash). + if !strings.Contains(stub.startReq, `-rel.tar.gz"`) { + t.Errorf("start input missing date-prefixed basename: %s", stub.startReq) + } + if !strings.Contains(stub.startReq, `"deployMessage":"release notes"`) { + t.Errorf("start input missing message: %s", stub.startReq) + } + // sha256 checksum (64 hex chars). + if !strings.Contains(stub.startReq, `"checksum":"`) { + t.Errorf("start input missing checksum: %s", stub.startReq) + } + content, _ := os.ReadFile(archive) // #nosec G304 + if string(stub.uploadedBody) != string(content) { + t.Errorf("uploaded body mismatch: %d vs %d bytes", len(stub.uploadedBody), len(content)) + } + if !strings.Contains(out.String(), "has been sent for deployment to example.com.") || + !strings.Contains(out.String(), "https://dashboard.wpvip.com/apps/42/develop/code/deployments") { + t.Errorf("out = %q", out.String()) + } +} + +func TestAppDeployPromptMismatchAborts(t *testing.T) { + stub := &deployStub{} + _ = newDeployCmd(stub, t) + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + restore := stubImportPrompts("WRONG.DOMAIN", true) + defer restore() + + cmd := AppDeployCmd() + cmd.SetContext(context.Background()) + // AppDeployCmd now registers -a/--app and -e/--env itself (Node parity); + // the test only needs to set them. + _ = cmd.Flags().Set("app", "myapp") + _ = cmd.Flags().Set("env", "develop") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := runAppDeploy(cmd, []string{deployArchive(t, "rel.tar.gz")}) + if err == nil || !strings.Contains(err.Error(), "The input did not match the expected environment label. Deploy aborted.") { + t.Errorf("err = %v", err) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if stub.startReq != "" { + t.Error("StartCustomDeploy must not fire after an aborted prompt") + } +} + +func TestAppDeployUncompressedFile(t *testing.T) { + stub := &deployStub{} + _ = newDeployCmd(stub, t) + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + + plain := filepath.Join(t.TempDir(), "rel.tgz") + if err := os.WriteFile(plain, []byte("not actually gzip"), 0o600); err != nil { + t.Fatal(err) + } + cmd := AppDeployCmd() + cmd.SetContext(context.Background()) + // AppDeployCmd now registers -a/--app and -e/--env itself (Node parity); + // the test only needs to set them. + _ = cmd.Flags().Set("app", "myapp") + _ = cmd.Flags().Set("env", "develop") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err := runAppDeploy(cmd, []string{plain}) + if err == nil || !strings.Contains(err.Error(), "Please compress file") { + t.Errorf("err = %v", err) + } +} + +func TestAppDeployValidateCleanArchive(t *testing.T) { + t.Setenv("NO_COLOR", "1") + cmd := AppDeployValidateCmd() + cmd.SetContext(context.Background()) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + + if err := runAppDeployValidate(cmd, []string{deployArchive(t, "rel.tar.gz")}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "✓ Compressed file has been successfully validated with no errors.") { + t.Errorf("out = %q", out.String()) + } +} + +func TestAppDeployValidateMissingThemes(t *testing.T) { + t.Setenv("NO_COLOR", "1") + // Archive without themes/ under root. + p := filepath.Join(t.TempDir(), "bad.tar.gz") + f, _ := os.Create(p) + zw := gzip.NewWriter(f) + tw := tar.NewWriter(zw) + _ = tw.WriteHeader(&tar.Header{Name: "app/", Typeflag: tar.TypeDir, Mode: 0o755}) + _ = tw.Close() + _ = zw.Close() + _ = f.Close() + + cmd := AppDeployValidateCmd() + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := runAppDeployValidate(cmd, []string{p}) + if err == nil || !strings.Contains(err.Error(), "Missing `themes` directory from root folder.") { + t.Errorf("err = %v", err) + } +} diff --git a/cmd/vip-next/commands/app_deploy_validate.go b/cmd/vip-next/commands/app_deploy_validate.go new file mode 100644 index 000000000..2740c8a62 --- /dev/null +++ b/cmd/vip-next/commands/app_deploy_validate.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/customdeploy" + "github.com/Automattic/vip/internal/upload" +) + +// AppDeployValidateCmd returns `vip app deploy validate `. +// +// Node parity: src/bin/vip-app-deploy-validate.ts. Local-only: file +// gates + archive-structure validation, no network and no deploy token. +func AppDeployValidateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate ", + Short: "Validate the directory structure of an archived file", + Long: "Validate the directory structure and contents of a local archived file (.zip, .tar.gz, " + + ".tgz) ahead of a Custom Deployment.", + Args: cobra.ExactArgs(1), + RunE: runAppDeployValidate, + } +} + +func runAppDeployValidate(cmd *cobra.Command, args []string) error { + fileName := args[0] + meta, err := upload.GetFileMeta(fileName) + if err != nil { + return fmt.Errorf("Unable to access file %s", fileName) + } + + if err := customdeploy.ValidateFile(meta, 0); err != nil { + return err + } + + trackEvent("deploy_validate_app_command_execute", nil) + + // vip-app-deploy-validate.ts:42 — .zip goes through the zip + // validator; everything else (tar.gz/tgz) through the tar validator. + if strings.ToLower(filepath.Ext(fileName)) == ".zip" { + err = customdeploy.ValidateZipFile(fileName) + } else { + err = customdeploy.ValidateTarFile(fileName) + } + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), color.GreenString("✓ Compressed file has been successfully validated with no errors.")) + return nil +} diff --git a/cmd/vip-next/commands/app_get.go b/cmd/vip-next/commands/app_get.go new file mode 100644 index 000000000..f80555674 --- /dev/null +++ b/cmd/vip-next/commands/app_get.go @@ -0,0 +1,293 @@ +package commands + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/output" +) + +// AppGetCmd is a non-registered cobra command that exists to document the +// `vip app ` form. Its execution path runs through +// appctx.WithWildcardCommand on the `vip app` parent (wired in root.go), not +// through the cobra dispatch tree. We keep this factory so help text + the +// --format flag are bound somewhere accessible for unit tests; production +// `--format` lives on the appCmd parent (see AppCmd). +func AppGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "", + Short: "Get application info", + Long: "Retrieve information about an application and its environments.", + } + // --format lives here for unit tests; in production the parent AppCmd owns it + // (the wildcard dispatcher reads flags from the parent, not this stub command). + cmd.Flags().StringP("format", "f", "table", + "Render output in a particular format. Accepts \"table\" (default), \"csv\", \"json\".") + return cmd +} + +// RunAppGet is the wildcard dispatcher target. Wired by root.go via +// appctx.WithWildcardCommand(appCmd, commands.RunAppGet). +// +// Behaviorally mirrors Node's vip-app.js: not-found and fetch-error paths +// print to stdout and exit 0; numeric arg → app(id:); non-numeric → apps(name:). +func RunAppGet(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("Please supply 1 argument: %s ", cmd.UseLine()) + } + data, err := runAppGet(cmd, args) + if err != nil { + return err + } + f, _ := cmd.Flags().GetString("format") + if f == "" { + f = "table" + } + allowed := map[string]bool{"table": true, "csv": true, "json": true} + if !allowed[f] { + return fmt.Errorf("Invalid format: %s. The supported formats are: table, csv, json.", f) + } + if data == nil { + return nil + } + cfg := GetConfig() + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_success", nil) + } + return output.Render(cmd.OutOrStdout(), output.Format(f), data) +} + +func runAppGet(cmd *cobra.Command, args []string) (any, error) { + cfg := GetConfig() + key := args[0] + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_execute", nil) + } + + if id, err := strconv.ParseInt(key, 10, 64); err == nil { + resp, qerr := gql.AppGetByID(cmd.Context(), cfg.GQLClient, id) + if qerr != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Unable to locate app %s: %s\n", key, qerr.Error()) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_fetch_error", map[string]any{"error": qerr.Error()}) + } + return nil, nil + } + // Node parity: `! res.environments` triggers not-found, but `[]` + // passes (JS: `![] === false`). nil slice == undefined; empty slice + // renders an empty env table. + if resp == nil || resp.App == nil || resp.App.Environments == nil { + fmt.Fprintf(cmd.OutOrStdout(), "App %s was not found\n", key) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_fetch_error", + map[string]any{"error": fmt.Sprintf("App %s does not exist", key)}) + } + return nil, nil + } + return buildAppGetOutput(resp.App.Environments), nil + } + + resp, err := gql.AppGetByName(cmd.Context(), cfg.GQLClient, key) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Unable to locate app %s: %s\n", key, err.Error()) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_fetch_error", map[string]any{"error": err.Error()}) + } + return nil, nil + } + if resp == nil || resp.Apps == nil || len(resp.Apps.Edges) == 0 || resp.Apps.Edges[0] == nil { + fmt.Fprintf(cmd.OutOrStdout(), "App %s was not found\n", key) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_fetch_error", + map[string]any{"error": fmt.Sprintf("App %s does not exist", key)}) + } + return nil, nil + } + edge := resp.Apps.Edges[0] + if edge.Environments == nil { + // Node parity: same `! res.environments` short-circuit as the byID + // path. nil slice == undefined. + fmt.Fprintf(cmd.OutOrStdout(), "App %s was not found\n", key) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_command_fetch_error", + map[string]any{"error": fmt.Sprintf("App %s does not exist", key)}) + } + return nil, nil + } + return buildAppGetOutput(edge.Environments), nil +} + +// buildAppGetOutput accepts an envs slice (the concrete type varies per query, +// hence reflectSliceToSlice) and returns only the environment rows. Node's +// vip-app.js does not return an app-level header. The rows omit +// deploymentStrategy and flatten primaryDomain to a string. +func buildAppGetOutput(envs any) output.OrderedRows { + rows := output.OrderedRows{} + for _, raw := range reflectSliceToSlice(envs) { + e := readEnvFieldsForAppGet(raw) + commit := e.currentCommit + if len(commit) > 7 { + commit = commit[:7] + } + branch := e.branch + if e.deploymentStrategy == "custom-deploy" { + branch = "-" + } + // getEnvIdentifier(env): "type" for the main env (where env.appId == + // env.id), else "type.name". See Node src/lib/cli/command.js. + identifier := e.type_ + if e.name != "" && e.name != e.type_ && e.appId != e.id { + identifier = e.type_ + "." + e.name + } + rows = append(rows, output.OrderedRow{ + {Key: "id", Value: e.id}, + {Key: "appId", Value: e.appId}, + {Key: "name", Value: identifier}, + {Key: "type", Value: e.type_}, + {Key: "branch", Value: branch}, + {Key: "currentCommit", Value: commit}, + {Key: "primaryDomain", Value: e.primaryDomain}, + {Key: "launched", Value: e.launched}, + }) + } + return rows +} + +type appGetEnvFields struct { + id int64 + appId int64 + name string + type_ string + branch string + currentCommit string + primaryDomain string + launched bool + deploymentStrategy string +} + +func reflectSliceToSlice(v any) []any { + rv := reflect.ValueOf(v) + if !rv.IsValid() || rv.Kind() != reflect.Slice { + return nil + } + out := make([]any, 0, rv.Len()) + for i := 0; i < rv.Len(); i++ { + out = append(out, rv.Index(i).Interface()) + } + return out +} + +func readEnvFieldsForAppGet(v any) appGetEnvFields { + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return appGetEnvFields{} + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return appGetEnvFields{} + } + var f appGetEnvFields + if v := derefFieldInt64(rv, "Id"); v != nil { + f.id = *v + } + if v := derefFieldInt64(rv, "AppId"); v != nil { + f.appId = *v + } + if v := derefFieldString(rv, "Name"); v != nil { + f.name = *v + } + if v := derefFieldString(rv, "Type"); v != nil { + f.type_ = *v + } + if v := derefFieldString(rv, "Branch"); v != nil { + f.branch = *v + } + if v := derefFieldString(rv, "CurrentCommit"); v != nil { + f.currentCommit = *v + } + if v := derefFieldString(rv, "DeploymentStrategy"); v != nil { + f.deploymentStrategy = *v + } + if v := derefFieldBool(rv, "Launched"); v != nil { + f.launched = *v + } + if pd := rv.FieldByName("PrimaryDomain"); pd.IsValid() { + // PrimaryDomain is a pointer to a per-query Domain struct with a + // (non-nullable) string Name field. + for pd.Kind() == reflect.Ptr { + if pd.IsNil() { + break + } + pd = pd.Elem() + } + if pd.Kind() == reflect.Struct { + if v := derefFieldString(pd, "Name"); v != nil { + f.primaryDomain = *v + } + } + } + return f +} + +func derefFieldInt64(rv reflect.Value, name string) *int64 { + f := rv.FieldByName(name) + if !f.IsValid() { + return nil + } + if f.Kind() == reflect.Ptr { + if f.IsNil() { + return nil + } + v := f.Elem().Int() + return &v + } + if f.Kind() == reflect.Int || f.Kind() == reflect.Int64 || f.Kind() == reflect.Int32 { + v := f.Int() + return &v + } + return nil +} + +func derefFieldString(rv reflect.Value, name string) *string { + f := rv.FieldByName(name) + if !f.IsValid() { + return nil + } + if f.Kind() == reflect.Ptr { + if f.IsNil() { + return nil + } + v := f.Elem().String() + return &v + } + if f.Kind() == reflect.String { + v := f.String() + return &v + } + return nil +} + +func derefFieldBool(rv reflect.Value, name string) *bool { + f := rv.FieldByName(name) + if !f.IsValid() { + return nil + } + if f.Kind() == reflect.Ptr { + if f.IsNil() { + return nil + } + v := f.Elem().Bool() + return &v + } + if f.Kind() == reflect.Bool { + v := f.Bool() + return &v + } + return nil +} diff --git a/cmd/vip-next/commands/app_get_test.go b/cmd/vip-next/commands/app_get_test.go new file mode 100644 index 000000000..46364b7ec --- /dev/null +++ b/cmd/vip-next/commands/app_get_test.go @@ -0,0 +1,226 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +func TestAppGetHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{ + "id":42,"name":"example-app","repo":"wpcomvip/example-app", + "environments":[ + {"id":7,"appId":42,"name":"develop","type":"develop","branch":"main", + "currentCommit":"abcdef1234567890","primaryDomain":{"name":"dev.example.com"}, + "launched":false,"deploymentStrategy":"git"} + ] + }]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"example-app"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + out := buf.String() + for _, want := range []string{"develop", "abcdef1"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q in:\n%s", want, out) + } + } + for _, unwanted := range []string{"id: 42", "name: example-app", "repo: wpcomvip/example-app"} { + if strings.Contains(out, unwanted) { + t.Errorf("Node app get returns only environment rows; output must not contain %q:\n%s", unwanted, out) + } + } + if strings.Contains(out, "abcdef1234567890") { + t.Errorf("currentCommit must be shortened to 7 chars; got full hash:\n%s", out) + } + if strings.Contains(out, "deploymentStrategy") || strings.Contains(out, "DEPLOYMENTSTRATEGY") { + t.Errorf("deploymentStrategy column must be hidden:\n%s", out) + } +} + +func TestAppGetByIDHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{ + "id":42,"name":"example-app","repo":"wpcomvip/example-app", + "environments":[ + {"id":42,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"abcdef1234567890","primaryDomain":{"name":"www.example.com"}, + "launched":true,"deploymentStrategy":"git"} + ] + }}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"42"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + out := buf.String() + // For the main env (env.appId == env.id), getEnvIdentifier returns "type". + for _, want := range []string{"www.example.com", "production"} { + if !strings.Contains(out, want) { + t.Errorf("byID output missing %q in:\n%s", want, out) + } + } + for _, unwanted := range []string{"id: 42", "name: example-app", "repo: wpcomvip/example-app"} { + if strings.Contains(out, unwanted) { + t.Errorf("Node app get returns only environment rows; output must not contain %q:\n%s", unwanted, out) + } + } +} + +func TestAppGetCSVDoesNotPrintAppHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{ + "id":42,"name":"example-app","repo":"wpcomvip/example-app", + "environments":[ + {"id":42,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"abcdef1234567890","primaryDomain":{"name":"www.example.com"}, + "launched":true,"deploymentStrategy":"git"} + ] + }}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("format", "csv") + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"42"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + out := buf.String() + if strings.Contains(out, "# id:") || strings.Contains(out, "# name:") || strings.Contains(out, "# repo:") { + t.Fatalf("CSV must contain only environment rows, got:\n%s", out) + } + if !strings.HasPrefix(out, `"id","app id","name"`) { + t.Fatalf("CSV must start with environment columns, got:\n%s", out) + } +} + +func TestAppGetNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"ghost"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + if !strings.Contains(buf.String(), "App ghost was not found") { + t.Errorf("not-found path must print Node-parity message; got: %s", buf.String()) + } +} + +func TestAppGetCustomDeployUsesDashBranch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{ + "id":42,"name":"x","repo":"r", + "environments":[ + {"id":7,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"abc1234","primaryDomain":{"name":"x.com"}, + "launched":true,"deploymentStrategy":"custom-deploy"} + ] + }]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("format", "json") + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"x"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + // The branch value should be "-" for custom-deploy. Use JSON assertions so + // the word "domain" in the humanized table header cannot match "main". + out := buf.String() + if strings.Contains(out, `"branch": "main"`) { + t.Errorf("branch should be \"-\" for custom-deploy, not \"main\"; got:\n%s", out) + } + if !strings.Contains(out, `"branch": "-"`) { + t.Errorf("expected dash for custom-deploy branch in:\n%s", out) + } +} + +func TestAppGetInvalidFormat(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{ + "id":1,"name":"x","repo":"r", + "environments":[{"id":1,"appId":1,"name":"production","type":"production", + "branch":"main","currentCommit":"abcdefg","primaryDomain":{"name":"x.com"}, + "launched":true,"deploymentStrategy":"git"}] + }]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("format", "yaml") + var buf bytes.Buffer + cmd.SetOut(&buf) + err := RunAppGet(cmd, []string{"x"}) + if err == nil || !strings.Contains(err.Error(), "Invalid format: yaml") { + t.Errorf("err = %v, want Node-parity invalid-format error", err) + } +} + +func TestAppGetJSONFormat(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{ + "id":1,"name":"x","repo":"r", + "environments":[{"id":1,"appId":1,"name":"production","type":"production", + "branch":"main","currentCommit":"abcdefg","primaryDomain":{"name":"x.com"}, + "launched":true,"deploymentStrategy":"git"}] + }]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppGetCmd() + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("format", "json") + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := RunAppGet(cmd, []string{"x"}); err != nil { + t.Fatalf("RunAppGet: %v", err) + } + out := buf.String() + if !strings.Contains(out, `"primaryDomain"`) || !strings.Contains(out, "x.com") { + t.Errorf("json output missing flattened primaryDomain: %s", out) + } +} diff --git a/cmd/vip-next/commands/app_list.go b/cmd/vip-next/commands/app_list.go new file mode 100644 index 000000000..d99598883 --- /dev/null +++ b/cmd/vip-next/commands/app_list.go @@ -0,0 +1,93 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/output" +) + +// AppListCmd returns the `vip app list` command. +// +// Wraps the genqlient AppList query. Empty results and fetch errors both +// print to stdout and exit 0 to match Node's vip-app-list.js behavior +// (see Automattic/vip-cli src/bin/vip-app-list.js). +func AppListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List applications", + Long: "Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user.", + } + // Node registers --format via the command factory before any bin option, + // so it wins the auto-derived -f (src/lib/cli/command.js:1090-1095). + cmd.Flags().StringP("format", "f", "table", + "Render output in a particular format. Accepts \"table\" (default), \"csv\", \"json\".") + cfg := GetConfig() + mw := []appctx.Middleware{} + if cfg.Tracker != nil { + mw = append(mw, appctx.WithTelemetry(cfg.Tracker, "app_list", nil)) + } + return appctx.Build(cmd, mw...).WithRenderableRun( + appctx.WithFormat(cmd, "table", "table", "csv", "json")(runAppList), + ) +} + +func runAppList(cmd *cobra.Command, args []string) (any, error) { + cfg := GetConfig() + first := int64(100) + resp, err := gql.AppList(cmd.Context(), cfg.GQLClient, &first, nil) + if err != nil { + // Node parity: print fetch errors to stdout and exit 0. + fmt.Fprintf(cmd.OutOrStdout(), "Failed to fetch apps: %s\n", err.Error()) + if cfg.Tracker != nil { + cfg.Tracker.TrackEvent("app_list_command_fetch_error", map[string]any{"error": err.Error()}) + } + return nil, nil + } + if resp.Apps == nil || len(resp.Apps.Edges) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No apps found") + return nil, nil + } + rows := make(output.OrderedRows, 0, len(resp.Apps.Edges)) + for _, e := range resp.Apps.Edges { + if e == nil { + continue + } + row := output.OrderedRow{ + {Key: "id", Value: derefAny(e.Id)}, + {Key: "name", Value: derefAny(e.Name)}, + {Key: "repo", Value: derefAny(e.Repo)}, + } + rows = append(rows, row) + } + return rows, nil +} + +// derefAny flattens a *T into its value, returning "" for nil pointers. +// Handles the genqlient pointer-optional fields M5 handlers consume. +func derefAny(v any) any { + switch p := v.(type) { + case *int64: + if p == nil { + return "" + } + return *p + case *int: + if p == nil { + return "" + } + return *p + case *string: + if p == nil { + return "" + } + return *p + case nil: + return "" + default: + return v + } +} diff --git a/cmd/vip-next/commands/app_list_test.go b/cmd/vip-next/commands/app_list_test.go new file mode 100644 index 000000000..539804fc7 --- /dev/null +++ b/cmd/vip-next/commands/app_list_test.go @@ -0,0 +1,95 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +func TestAppListRendersTable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"total":2,"nextCursor":null,"edges":[ + {"id":8886,"name":"example-app","repo":"wpcomvip/example-app"}, + {"id":4325,"name":"mytestmultisite","repo":"wpcomvip/mytestmultisite"} + ]}}}`)) + })) + defer srv.Close() + + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppListCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + out := buf.String() + for _, want := range []string{"8886", "example-app", "wpcomvip/example-app", "4325", "mytestmultisite"} { + if !strings.Contains(out, want) { + t.Errorf("table output missing %q in:\n%s", want, out) + } + } +} + +func TestAppListEmptyPrintsMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"total":0,"nextCursor":null,"edges":[]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppListCmd() + cmd.SetContext(context.Background()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(stdout.String(), "No apps found") { + t.Errorf("empty case must print 'No apps found' (Node parity); got: %q", stdout.String()) + } +} + +func TestAppListJSONFormat(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"total":1,"nextCursor":null,"edges":[ + {"id":1,"name":"x","repo":"r"} + ]}}}`)) + })) + defer srv.Close() + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client())}) + defer SetConfig(Config{}) + + cmd := AppListCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetArgs([]string{"--format=json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(buf.String(), `"id": 1`) && !strings.Contains(buf.String(), `"id":1`) { + t.Errorf("json output missing id field: %s", buf.String()) + } +} + +func TestAppListInvalidFormatRejected(t *testing.T) { + cmd := AppListCmd() + cmd.SetContext(context.Background()) + var buf bytes.Buffer + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--format=yaml"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "Invalid format: yaml") { + t.Errorf("err = %v, want Node-parity invalid-format error", err) + } +} diff --git a/cmd/vip-next/commands/backup.go b/cmd/vip-next/commands/backup.go new file mode 100644 index 000000000..43921b715 --- /dev/null +++ b/cmd/vip-next/commands/backup.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/spf13/cobra" + +// BackupCmd returns the `vip backup` parent. Children attach in root.go; +// the parent itself just prints help (Node: src/bin/vip-backup.ts). +func BackupCmd() *cobra.Command { + return &cobra.Command{ + Use: "backup", + Short: "Generate backups for an environment", + Long: "Generate database backups for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/backup_db.go b/cmd/vip-next/commands/backup_db.go new file mode 100644 index 000000000..e449345d2 --- /dev/null +++ b/cmd/vip-next/commands/backup_db.go @@ -0,0 +1,137 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/backup" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/tui" +) + +// BackupDBCmd returns `vip backup db`. +// +// Node parity: src/bin/vip-backup-db.ts + src/commands/backup-db.ts. +// Triggers a database backup (unless one is already running) and polls +// the db_backup job until its in-progress lock clears. +func BackupDBCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "db", + Short: "Generate a new database backup of an environment", + Long: "Generate a new database backup of a VIP Platform environment. If a backup is already " + + "in progress, the command attaches to it and polls until completion.", + Args: cobra.NoArgs, + } + addAppEnvFlags(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runBackupDB) +} + +// backupPollInterval — VIP_BACKUP_DB_INTERVAL_MS overrides the 1s Node +// default for tests. +func backupPollInterval() time.Duration { + if v := os.Getenv("VIP_BACKUP_DB_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return backup.DefaultPollInterval +} + +// backupPollTimeout — VIP_BACKUP_DB_TIMEOUT_MS overrides Node's 6h pollUntil +// ceiling (backup-db.ts:198 → utils.ts:18) so the ceiling is reachable in a +// test. Same knob shape as VIP_BACKUP_DB_INTERVAL_MS. +func backupPollTimeout() time.Duration { + if v := os.Getenv("VIP_BACKUP_DB_TIMEOUT_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return backup.DefaultPollTimeout +} + +// fetchBackupJob flattens AppBackupJobStatus into backup.Job +// (backup-db.ts:53,129). +func fetchBackupJob(ctx context.Context, appID, envID int64) (*backup.Job, error) { + cfg := GetConfig() + resp, err := gql.AppBackupJobStatus(ctx, cfg.GQLClient, appID, envID) + if err != nil { + return nil, err + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + return nil, nil + } + jobs := resp.App.Environments[0].Jobs + if len(jobs) == 0 || jobs[0] == nil { + return nil, nil + } + job := *jobs[0] + out := &backup.Job{BackupName: "Unknown"} + if lock := job.GetInProgressLock(); lock != nil { + out.InProgressLock = *lock + } + if c := job.GetCompletedAt(); c != nil { + out.CompletedAt = *c + } + if p := job.GetProgress(); p != nil && p.Status != nil { + out.Status = *p.Status + } + for _, m := range job.GetMetadata() { + if m != nil && m.Name != nil && *m.Name == "backupName" && m.Value != nil { + out.BackupName = *m.Value + } + } + return out, nil +} + +func runBackupDB(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + + trackEvent("backup_db_execute", nil) + + pt := tui.NewProgressTracker([]tui.ProgressStep{ + {ID: backup.StepPrepare, Name: "Preparing for backup generation"}, + {ID: backup.StepGenerate, Name: "Generating backup"}, + }) + renderer := startBackupProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + pollCtx := gql.WithAllowGQLErrors(cmd.Context()) + err := backup.Run(pollCtx, backup.RunOpts{ + Fetch: func(ctx context.Context) (*backup.Job, error) { + return fetchBackupJob(ctx, ae.App.ID, ae.Env.ID) + }, + Create: func(ctx context.Context) error { + input := &gql.AppEnvironmentTriggerDBBackupInput{Id: ae.App.ID, EnvironmentId: ae.Env.ID} + _, err := gql.TriggerDatabaseBackup(ctx, cfg.GQLClient, input) + return err + }, + Tracker: pt, + Interval: backupPollInterval(), + Timeout: backupPollTimeout(), + Log: func(msg string) { fmt.Fprintln(out, msg) }, + FinalizeProgress: func() { renderer.stopCompact(cmd, true) }, + }) + if err != nil { + renderer.stop(cmd, true) + return err + } + renderer.stop(cmd, true) + trackEvent("backup_db_success", nil) + return nil +} diff --git a/cmd/vip-next/commands/backup_db_test.go b/cmd/vip-next/commands/backup_db_test.go new file mode 100644 index 000000000..aec14033c --- /dev/null +++ b/cmd/vip-next/commands/backup_db_test.go @@ -0,0 +1,187 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/backup" +) + +// backupStub serves AppBackupJobStatus (sequenced) + TriggerDatabaseBackup. +type backupStub struct { + statusBodies []string + statusHits atomic.Int32 + triggerHits atomic.Int32 +} + +func backupJobBody(lock bool, status string) string { + return `{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[ + {"__typename":"Job","id":1,"type":"db_backup","completedAt":"2026-06-11 10:00:00","createdAt":"2026-06-11 09:00:00", + "inProgressLock":` + boolStr(lock) + `, + "metadata":[{"name":"backupName","value":"backup-1"}], + "progress":{"status":"` + status + `"}}]}]}}}` +} + +func boolStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func (s *backupStub) start(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"AppBackupJobStatus"`): + i := int(s.statusHits.Add(1) - 1) + if i >= len(s.statusBodies) { + i = len(s.statusBodies) - 1 + } + _, _ = w.Write([]byte(s.statusBodies[i])) + case strings.Contains(bs, `"operationName":"TriggerDatabaseBackup"`): + s.triggerHits.Add(1) + _, _ = w.Write([]byte(`{"data":{"triggerDatabaseBackup":{"success":true}}}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func setupBackupTest(t *testing.T, stub *backupStub) { + t.Helper() + srv := stub.start(t) + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), APIHost: srv.URL, Token: "tok"}) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_BACKUP_DB_INTERVAL_MS", "1") +} + +// TestBackupPollTimeoutKnob: `vip backup db` inherits Node's 6h pollUntil +// ceiling (backup-db.ts:198 passes no timeout), and the ceiling is +// overridable with the same VIP_*_MS knob shape as the interval so it can +// be exercised without a six-hour test. +func TestBackupPollTimeoutKnob(t *testing.T) { + if got := backupPollTimeout(); got != backup.DefaultPollTimeout { + t.Errorf("backupPollTimeout() = %v, want %v", got, backup.DefaultPollTimeout) + } + t.Setenv("VIP_BACKUP_DB_TIMEOUT_MS", "25") + if got := backupPollTimeout(); got != 25*time.Millisecond { + t.Errorf("with VIP_BACKUP_DB_TIMEOUT_MS=25: %v, want 25ms", got) + } +} + +// TestBackupDBStopsAtPollCeiling drives the whole command against a backup +// job whose inProgressLock never clears. Before the ceiling was ported this +// spun forever with nothing cancelling the context. +func TestBackupDBStopsAtPollCeiling(t *testing.T) { + stub := &backupStub{statusBodies: []string{backupJobBody(true, "running")}} + setupBackupTest(t, stub) + t.Setenv("VIP_BACKUP_DB_TIMEOUT_MS", "30") + + cmd := BackupDBCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + done := make(chan error, 1) + go func() { done <- runBackupDB(cmd, nil) }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "Polling timed out") { + t.Errorf("err = %v, want a %q failure", err, "Polling timed out") + } + case <-time.After(5 * time.Second): + t.Fatal("runBackupDB never returned: the poll loop is unbounded") + } +} + +func TestBackupDBHappyPath(t *testing.T) { + stub := &backupStub{statusBodies: []string{ + `{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[]}]}}}`, // no job yet + backupJobBody(true, "running"), + backupJobBody(false, "success"), + }} + setupBackupTest(t, stub) + + cmd := BackupDBCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runBackupDB(cmd, nil); err != nil { + t.Fatalf("runBackupDB: %v\nstdout: %s", err, stdout.String()) + } + out := stdout.String() + if !strings.Contains(out, "Generating a new database backup...") || + !strings.Contains(out, "New database backup created") { + t.Errorf("stdout = %q", out) + } + if stub.triggerHits.Load() != 1 { + t.Errorf("TriggerDatabaseBackup hits = %d", stub.triggerHits.Load()) + } +} + +func TestBackupDBAlreadyInProgress(t *testing.T) { + stub := &backupStub{statusBodies: []string{ + backupJobBody(true, "running"), + backupJobBody(false, "success"), + }} + setupBackupTest(t, stub) + + cmd := BackupDBCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runBackupDB(cmd, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "Database backup already in progress...") { + t.Errorf("stdout = %q", stdout.String()) + } + finalStep := strings.Index(stdout.String(), "✓ Generating backup") + successMessage := strings.Index(stdout.String(), "New database backup created") + if finalStep == -1 || successMessage == -1 || finalStep > successMessage { + t.Errorf("final progress frame must precede the success message; stdout = %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "✓ Generating backup \nNew database backup created\n") { + t.Errorf("success message must immediately follow the final progress frame; stdout = %q", stdout.String()) + } + if stub.triggerHits.Load() != 0 { + t.Error("Trigger must not fire when a backup is already running") + } +} + +func TestBackupDBFinalFailure(t *testing.T) { + stub := &backupStub{statusBodies: []string{ + `{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[]}]}}}`, + backupJobBody(false, "failed"), + }} + setupBackupTest(t, stub) + + cmd := BackupDBCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + err := runBackupDB(cmd, nil) + if err == nil || err.Error() != "Failed to create a new database backup" { + t.Errorf("err = %v", err) + } +} diff --git a/cmd/vip-next/commands/cache.go b/cmd/vip-next/commands/cache.go new file mode 100644 index 000000000..21de863f4 --- /dev/null +++ b/cmd/vip-next/commands/cache.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/spf13/cobra" + +// CacheCmd returns the `vip cache` parent. Subcommands are attached in +// root.go; the parent itself just prints help. +func CacheCmd() *cobra.Command { + return &cobra.Command{ + Use: "cache", + Short: "Manage edge cache for a VIP Platform environment", + Long: "Manage edge cache for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/cache_purge_url.go b/cmd/vip-next/commands/cache_purge_url.go new file mode 100644 index 000000000..b343f1653 --- /dev/null +++ b/cmd/vip-next/commands/cache_purge_url.go @@ -0,0 +1,90 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/cachepurge" +) + +// CachePurgeURLCmd returns `vip cache purge-url [URLs...]`. +// +// Node parity: src/bin/vip-cache-purge-url.js. Variadic positional URLs OR +// --from-file=; --from-file fully REPLACES positional args when set +// (after readFromFile().trim(), Node does `urls = value.split('\n').map(...)`, +// overwriting whatever positional `urls` came in). No prompt — cache purge +// is a benign no-op when targeting URLs that aren't currently cached. +func CachePurgeURLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "purge-url [URLs...]", + Short: "Purge URLs from the page cache for an environment", + Long: "Purge one or more URLs from the page cache. URLs can be supplied as positional arguments or read from a file via --from-file.\n\n" + + "When --from-file is used, the file is split on newlines and each line is trimmed; empty lines are dropped. Positional URLs are ignored.", + Args: cobra.ArbitraryArgs, + } + cmd.Flags().StringP("from-file", "f", "", "Read one or more URLs from a file, each listed on a single line.") + return buildAppEnvCmd(cmd, runCachePurgeURL) +} + +func runCachePurgeURL(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + fromFile, _ := cmd.Flags().GetString("from-file") + trackEvent("cache_purge_url_command_execute", map[string]any{ + "from_file": fromFile != "", + }) + + urls := args + if fromFile != "" { + // Node parity: readFromFile().trim() THEN split('\n').map(trim). + // Trimming the whole blob first strips trailing newlines so we don't + // emit a spurious empty-URL entry, then per-line TrimSpace handles + // stray CRs/spaces. Empty lines are dropped (Node sends empty strings + // to the server, which rejects them — we drop client-side to match + // what the Go test fixtures + server expectations look like). + b, err := os.ReadFile(fromFile) + if err != nil { + trackEvent("cache_purge_url_command_error", map[string]any{"error": "read_file"}) + return fmt.Errorf("read %s: %w", fromFile, err) + } + body := strings.TrimSpace(string(b)) + urls = nil + if body != "" { + for _, line := range strings.Split(body, "\n") { + t := strings.TrimSpace(line) + if t != "" { + urls = append(urls, t) + } + } + } + } + + if len(urls) == 0 { + trackEvent("cache_purge_url_command_error", map[string]any{"error": "No URL provided"}) + // Node's exit.withError prints "Error: " to stderr and exits 1; + // returning a non-nil error from RunE lets cobra surface it to stderr + // with its own "Error: " prefix, matching the Node wording. + return errors.New("Please supply at least one URL.") + } + + result, err := cachepurge.Purge(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, urls) + if err != nil { + trackEvent("cache_purge_url_command_error", map[string]any{"error": err.Error()}) + return fmt.Errorf("Failed to purge URL(s) from page cache: %w", err) + } + + trackEvent("cache_purge_url_command_success", nil) + for _, u := range result { + fmt.Fprintf(cmd.OutOrStdout(), "- Purged URL: %s\n", u) + } + return nil +} diff --git a/cmd/vip-next/commands/cache_purge_url_test.go b/cmd/vip-next/commands/cache_purge_url_test.go new file mode 100644 index 000000000..23afb59dc --- /dev/null +++ b/cmd/vip-next/commands/cache_purge_url_test.go @@ -0,0 +1,154 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// cachePurgeStub mirrors envvarMutationStub but records every body so the +// IGNORED-positional test can assert what the mutation actually sent. +type cachePurgeStub struct { + mu sync.Mutex + lastBody string + hits int + respBody string +} + +func (s *cachePurgeStub) start(_ *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.lastBody = string(body) + s.hits++ + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if s.respBody == "" { + _, _ = w.Write([]byte(`{"data":null}`)) + return + } + _, _ = w.Write([]byte(s.respBody)) + })) +} + +func (s *cachePurgeStub) body() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastBody +} + +func (s *cachePurgeStub) hitCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.hits +} + +func TestCachePurgeURLSinglePositional(t *testing.T) { + stub := &cachePurgeStub{ + respBody: `{"data":{"purgePageCache":{"success":true,"urls":["https://example-app.go-vip.co/sample-page/"]}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := CachePurgeURLCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runCachePurgeURL(cmd, []string{"https://example-app.go-vip.co/sample-page/"}); err != nil { + t.Fatalf("runCachePurgeURL: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "- Purged URL: https://example-app.go-vip.co/sample-page/") { + t.Errorf("stdout missing per-URL line; got %q", out) + } + body := stub.body() + if !strings.Contains(body, `"operationName":"PurgePageCache"`) { + t.Errorf("expected PurgePageCache op; body=%s", body) + } + if !strings.Contains(body, `"urls":["https://example-app.go-vip.co/sample-page/"]`) { + t.Errorf("expected single URL in input; body=%s", body) + } +} + +// TestCachePurgeURLFromFileReplacesPositional confirms --from-file fully +// REPLACES positional URLs (Node parity: the variable `urls` is reassigned +// unconditionally inside the `if (opt.fromFile)` branch). The positional +// IGNORED URL must NOT appear in the wire body. +func TestCachePurgeURLFromFileReplacesPositional(t *testing.T) { + stub := &cachePurgeStub{ + respBody: `{"data":{"purgePageCache":{"success":true,"urls":["https://a.example.com/","https://b.example.com/"]}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + dir := t.TempDir() + urlsPath := filepath.Join(dir, "urls.txt") + // Mix in blank lines + trailing/leading whitespace to exercise the + // per-line TrimSpace and empty-line drop. + if err := os.WriteFile(urlsPath, []byte("https://a.example.com/\n https://b.example.com/ \n\n"), 0600); err != nil { + t.Fatalf("write urls.txt: %v", err) + } + + cmd := CachePurgeURLCmd() + _ = cmd.Flags().Set("from-file", urlsPath) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + // Positional URL that must be IGNORED. + if err := runCachePurgeURL(cmd, []string{"https://example.com/IGNORED"}); err != nil { + t.Fatalf("runCachePurgeURL: %v", err) + } + + body := stub.body() + if strings.Contains(body, "IGNORED") { + t.Errorf("--from-file must replace positional URLs; IGNORED leaked into body=%s", body) + } + if !strings.Contains(body, `"urls":["https://a.example.com/","https://b.example.com/"]`) { + t.Errorf("expected URLs from file (trimmed, no empties); body=%s", body) + } + out := stdout.String() + if !strings.Contains(out, "- Purged URL: https://a.example.com/") || !strings.Contains(out, "- Purged URL: https://b.example.com/") { + t.Errorf("stdout missing both purged URLs; got %q", out) + } +} + +// TestCachePurgeURLEmptyExits1 covers the no-positional/no-from-file path. +// The mutation must NOT fire — the empty check runs before the GraphQL call. +func TestCachePurgeURLEmptyExits1(t *testing.T) { + stub := &cachePurgeStub{respBody: `{"data":null}`} + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := CachePurgeURLCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + err := runCachePurgeURL(cmd, nil) + if err == nil { + t.Fatal("expected error for empty URL list, got nil") + } + if !strings.Contains(err.Error(), "Please supply at least one URL.") { + t.Errorf("error must match Node text; got %q", err.Error()) + } + if stub.hitCount() != 0 { + t.Errorf("mutation must not fire on empty URL list; hits=%d body=%s", stub.hitCount(), stub.body()) + } +} diff --git a/cmd/vip-next/commands/config.go b/cmd/vip-next/commands/config.go new file mode 100644 index 000000000..811c0c943 --- /dev/null +++ b/cmd/vip-next/commands/config.go @@ -0,0 +1,17 @@ +package commands + +import "github.com/spf13/cobra" + +// ConfigCmd returns the `vip config` parent. Subcommands (currently just +// `envvar`) are attached in root.go so the test harness can opt into the +// subtree it needs without dragging the others. +func ConfigCmd() *cobra.Command { + return &cobra.Command{ + Use: "config", + Short: "Manage environment configuration", + Long: "Manage configuration for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/config_envvar.go b/cmd/vip-next/commands/config_envvar.go new file mode 100644 index 000000000..3c66553c9 --- /dev/null +++ b/cmd/vip-next/commands/config_envvar.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/spf13/cobra" + +// ConfigEnvvarCmd returns the `vip config envvar` parent. Leaf commands +// list / get / get-all attach in root.go. +func ConfigEnvvarCmd() *cobra.Command { + return &cobra.Command{ + Use: "envvar", + Short: "Manage environment variables", + Long: "Manage environment variables for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/config_envvar_delete.go b/cmd/vip-next/commands/config_envvar_delete.go new file mode 100644 index 000000000..e40b17d68 --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_delete.go @@ -0,0 +1,133 @@ +package commands + +import ( + "errors" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/envvar" + "github.com/Automattic/vip/internal/exit" +) + +// ConfigEnvvarDeleteCmd returns `vip config envvar delete `. +// +// Mutation wrapper around deleteEnvironmentVariable. Interactive flow +// when --skip-confirmation is absent (Node parity, src/bin/vip-config-envvar-delete.js): +// +// 1. Production prod-gate: confirms against env name + app name (only on prod). +// 2. ValidateName: rejects malformed names with the Node-parity error text. +// 3. Text input: user must type the variable name exactly ("Type FOO to confirm deletion:"). +// 4. Yes/no: "Are you sure? Deletion is permanent" (red+bold). +// 5. promptForReloadManifest: "Apply this environment variable update now?". +// +// All interactive gates short-circuit to no-op under --skip-confirmation OR +// VIP_NON_INTERACTIVE=1 (parity scenarios use one or the other). Decline at +// any gate prints a Node-parity cancel line to stdout + exits 0; the mutation +// does not fire. +func ConfigEnvvarDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an environment variable", + Long: "Permanently delete an environment variable from the target environment.", + Args: cobra.ExactArgs(1), + } + + addAppEnvFlags(cmd) + // vip-config-envvar-delete.js registers --skip-confirmation itself, so it + // takes the auto-derived -s. + cmd.Flags().BoolP("skip-confirmation", "s", false, "Skip confirmation prompts.") + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithSkipConfirmationFlag(cmd), + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runEnvvarDelete) +} + +func runEnvvarDelete(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + // Node parity: vip-config-envvar-delete.js uppercases + trims args[0] + // before any validation. + name := strings.ToUpper(strings.TrimSpace(args[0])) + + trackEvent("envvar_delete_command_execute", map[string]any{"variable_name": name}) + + // Production prod-gate (inline because the message interpolates name + app). + skipConfirm, _ := cmd.Flags().GetBool("skip-confirmation") + if !skipConfirm && ae.Env.Type == "production" { + msg := fmt.Sprintf("Are you sure you want to delete the environment variable %s on %s for site %s?", name, formatEnvironment(ae.Env.Type), ae.App.Name) + ok, err := appctx.Confirm(cmd, msg, false) + if errors.Is(err, appctx.ErrNonInteractive) || (err == nil && !ok) { + trackEvent("envvar_delete_command_cancelled", nil) + fmt.Fprintln(cmd.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + } + + if err := envvar.ValidateName(name); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), color.RedString(err.Error())) + trackEvent("envvar_delete_command_error", map[string]any{"error": "invalid_name"}) + return exit.Handled(err) + } + + // Node parity (src/bin/vip-config-envvar-delete.js): double-confirm gate — + // first ask the user to type the variable name, then ask a yes/no. + // Both decline paths emit a yellow cancel message and return nil (exit 0). + // Telemetry events split per cancel path. + if !skipConfirm { + typed, err := appctx.Input(cmd, fmt.Sprintf("Type %s to confirm deletion:", name), "") + if errors.Is(err, appctx.ErrNonInteractive) || (err == nil && typed != name) { + fmt.Fprintln(cmd.OutOrStdout(), color.YellowString("Command cancelled by user.")) + trackEvent("envvar_delete_user_cancelled_input", nil) + return nil + } + if err != nil { + return err + } + + msg := fmt.Sprintf("Are you sure? %s", color.New(color.FgRed, color.Bold).Sprint("Deletion is permanent")) + ok, err := appctx.Confirm(cmd, msg, false) + if errors.Is(err, appctx.ErrNonInteractive) || (err == nil && !ok) { + fmt.Fprintln(cmd.OutOrStdout(), color.YellowString("Command cancelled by user.")) + trackEvent("envvar_delete_user_cancelled_confirmation", nil) + return nil + } + if err != nil { + return err + } + } + + // Node parity (src/bin/vip-config-envvar-delete.js): ask whether to apply + // the update now, then pass through to reloadManifest on the mutation + // input. Short-circuits to false on --skip-confirmation / non-interactive. + reloadManifest, _ := envvar.PromptForReloadManifest(cmd, ae.App.TypeId, skipConfirm) + + if err := envvar.Delete(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, name, reloadManifest); err != nil { + trackEvent("envvar_delete_command_error", map[string]any{"error": err.Error()}) + return err + } + + trackEvent("envvar_delete_command_success", map[string]any{"variable_name": name}) + fmt.Fprintln(cmd.OutOrStdout(), + color.GreenString(fmt.Sprintf(`Successfully deleted environment variable "%s"`, name))) + + // Node parity: delete's success path only emits showDeployWarning when + // the user declined the reload AND was actually prompted (not skipConfirm). + // Unlike set, delete has no "active and available" message. + if !skipConfirm && !reloadManifest { + envvar.ShowDeployWarning(cmd.OutOrStdout()) + } + return nil +} diff --git a/cmd/vip-next/commands/config_envvar_delete_test.go b/cmd/vip-next/commands/config_envvar_delete_test.go new file mode 100644 index 000000000..167a85ceb --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_delete_test.go @@ -0,0 +1,140 @@ +package commands + +import ( + "bytes" + "strings" + "testing" +) + +func TestEnvvarDeleteSkipConfirmation(t *testing.T) { + stub := &envvarMutationStub{ + respBody: `{"data":{"deleteEnvironmentVariable":{"environmentVariables":{"total":0,"nodes":[]}}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarDeleteCmd() + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarDelete(cmd, []string{"my_var"}); err != nil { + t.Fatalf("runEnvvarDelete: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, `Successfully deleted environment variable "MY_VAR"`) { + t.Errorf("stdout = %q, want success delete message with uppercased quoted name", out) + } + body := stub.body() + if !strings.Contains(body, `"operationName":"DeleteEnvironmentVariable"`) { + t.Errorf("expected DeleteEnvironmentVariable op; body=%s", body) + } + if !strings.Contains(body, `"value":""`) { + t.Errorf("delete must send empty-string value; body=%s", body) + } + if !strings.Contains(body, `"name":"MY_VAR"`) { + t.Errorf("expected uppercased name=MY_VAR; body=%s", body) + } +} + +// TestEnvvarDeletePassesReloadManifestFalseWhenSkipConfirmation pins the +// wire-level shape: --skip-confirmation short-circuits the prompt to false +// and that value is forwarded to the mutation input. +func TestEnvvarDeletePassesReloadManifestFalseWhenSkipConfirmation(t *testing.T) { + stub := &envvarMutationStub{ + respBody: `{"data":{"deleteEnvironmentVariable":{"environmentVariables":{"total":0,"nodes":[]}}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarDeleteCmd() + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarDelete(cmd, []string{"FOO"}); err != nil { + t.Fatalf("runEnvvarDelete: %v", err) + } + if !strings.Contains(stub.body(), `"reloadManifest":false`) { + t.Errorf("mutation body must include reloadManifest:false on --skip-confirmation; body=%s", stub.body()) + } + // --skip-confirmation must also suppress the post-success deploy warning. + if strings.Contains(stdout.String(), "Important:") { + t.Errorf("ShowDeployWarning must NOT fire under --skip-confirmation; stdout=%q", stdout.String()) + } +} + +// TestEnvvarDeleteInputGateCancelsOnNonInteractive: VIP_NON_INTERACTIVE=1 +// forces Input to return ErrNonInteractive, which the handler treats as +// decline. Mutation must NOT fire. +// +// Coverage gap noted: the SECOND gate ("Are you sure? Deletion is permanent") +// fires only after the first gate passes with a correctly typed name. +// VIP_NON_INTERACTIVE flips both Input and Confirm to ErrNonInteractive +// uniformly, so the first gate always wins under that mechanism. The second +// gate is structurally identical (same three-branch ErrNonInteractive +// pattern) and is exercised manually during staging smoke; an injectable- +// stdin test harness is the proper path to close this gap (out of scope +// for M6b — flagged for the M7 import sub-project which has similar +// interactive-prompt test needs). +func TestEnvvarDeleteInputGateCancelsOnNonInteractive(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + stub := &envvarMutationStub{respBody: `{"data":null}`} + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarDeleteCmd() // NOT skip-confirmation + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarDelete(cmd, []string{"FOO"}); err != nil { + t.Fatalf("expected nil clean cancel; got %v", err) + } + if !strings.Contains(stdout.String(), "Command cancelled by user.") { + t.Errorf("expected Node-parity cancel wording; got %q", stdout.String()) + } + if strings.Contains(stub.body(), "DeleteEnvironmentVariable") { + t.Errorf("mutation must NOT fire on typed-mismatch cancel; body=%s", stub.body()) + } +} + +func TestEnvvarDeleteInvalidName(t *testing.T) { + stub := &envvarMutationStub{respBody: `{"data":null}`} + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarDeleteCmd() + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + err := runEnvvarDelete(cmd, []string{"bad-name-with-dash"}) + if err == nil { + t.Fatal("expected error for invalid name, got nil") + } + requireAlreadyPrintedError(t, err) + if !strings.Contains(stdout.String(), "A-Z, 0-9, or _") { + t.Errorf("stdout must include Node-parity error text; got %q", stdout.String()) + } + // Mutation must NOT have been called. + if strings.Contains(stub.body(), `"operationName":"DeleteEnvironmentVariable"`) { + t.Errorf("mutation must not fire on invalid name; body=%s", stub.body()) + } +} diff --git a/cmd/vip-next/commands/config_envvar_get.go b/cmd/vip-next/commands/config_envvar_get.go new file mode 100644 index 000000000..788eba304 --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_get.go @@ -0,0 +1,70 @@ +package commands + +import ( + "errors" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/envvar" +) + +// ConfigEnvvarGetCmd returns `vip config envvar get `. +// +// Single-name fetch is implemented client-side (envvar.Get filters the +// get-all result) because the schema exposes no per-name query — see +// envvar/envvar.go. Node parity: src/bin/vip-config-envvar-get.js +// uppercases the argument and prints a yellow not-found stdout message +// + exit 0 when the variable is missing. +func ConfigEnvvarGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get the value of an environment variable", + Long: "Retrieve the value of a specific environment variable.", + } + wrapped := buildAppEnvCmd(cmd, runEnvvarGet) + // Argv-count enforcement runs after the middleware chain so the error + // message names the final command. Node parity: requiredArgs: 1. + prev := wrapped.RunE + wrapped.RunE = func(c *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("Please supply 1 argument: %s ", c.UseLine()) + } + return prev(c, args) + } + return wrapped +} + +func runEnvvarGet(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("Please supply 1 argument: %s ", cmd.UseLine()) + } + // Help the user by uppercasing input — Node parity. + name := strings.ToUpper(strings.TrimSpace(args[0])) + + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + trackEvent("envvar_get_command_execute", map[string]any{"variable_name": name}) + v, err := envvar.Get(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, name) + if err != nil { + trackEvent("envvar_get_query_error", map[string]any{"variable_name": name, "error": err.Error()}) + return err + } + trackEvent("envvar_get_command_success", map[string]any{"variable_name": name}) + + if v == nil { + // Node renders the name via JSON.stringify (quoted) — Go's %q matches. + fmt.Fprintln(cmd.OutOrStdout(), + color.YellowString(fmt.Sprintf("The environment variable %q does not exist", name))) + return nil + } + fmt.Fprintln(cmd.OutOrStdout(), v.Value) + return nil +} diff --git a/cmd/vip-next/commands/config_envvar_get_all.go b/cmd/vip-next/commands/config_envvar_get_all.go new file mode 100644 index 000000000..0c1f237fb --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_get_all.go @@ -0,0 +1,56 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/envvar" + "github.com/Automattic/vip/internal/output" +) + +// ConfigEnvvarGetAllCmd returns `vip config envvar get-all`. Wraps the +// GetEnvironmentVariablesWithValues genqlient query. Empty results print +// the Node-parity yellow stdout message + exit 0. Column order matches +// Node's formatData output: , value. +func ConfigEnvvarGetAllCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get-all", + Short: "Retrieve all environment variables and their values", + Long: "Retrieve a list of all environment variables and their values.", + } + addFormatFlagWithShort(cmd) + return buildAppEnvRenderableCmd(cmd, "table", + []string{"table", "csv", "json", "keyValue", "ids"}, runEnvvarGetAll) +} + +func runEnvvarGetAll(cmd *cobra.Command, args []string) (any, error) { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + trackEvent("envvar_get_all_command_execute", nil) + vars, err := envvar.GetAll(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + trackEvent("envvar_get_all_query_error", map[string]any{"error": err.Error()}) + return nil, err + } + trackEvent("envvar_get_all_command_success", nil) + if len(vars) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), color.YellowString("There are no environment variables")) + return nil, nil + } + keyName := envvarKeyForFormat(cmd) + rows := make(output.OrderedRows, 0, len(vars)) + for _, v := range vars { + rows = append(rows, output.OrderedRow{ + {Key: keyName, Value: v.Name}, + {Key: "value", Value: v.Value}, + }) + } + return rows, nil +} diff --git a/cmd/vip-next/commands/config_envvar_get_all_test.go b/cmd/vip-next/commands/config_envvar_get_all_test.go new file mode 100644 index 000000000..ef42c649f --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_get_all_test.go @@ -0,0 +1,83 @@ +package commands + +import ( + "bytes" + "strings" + "testing" + + "github.com/Automattic/vip/internal/output" +) + +func TestConfigEnvvarGetAllReturnsValues(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO","value":"1"},{"name":"BAR","value":"two"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetAllCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarGetAll(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarGetAll: %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want OrderedRows", data) + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + if rows[0][0].Key != "name" || rows[0][1].Key != "value" { + t.Errorf("row 0 columns = %s/%s, want name/value", rows[0][0].Key, rows[0][1].Key) + } + if rows[0][0].Value.(string) != "FOO" || rows[0][1].Value.(string) != "1" { + t.Errorf("row 0 = %+v, want FOO/1", rows[0]) + } + if rows[1][0].Value.(string) != "BAR" || rows[1][1].Value.(string) != "two" { + t.Errorf("row 1 = %+v, want BAR/two", rows[1]) + } +} + +func TestConfigEnvvarGetAllKeyValueChangesColumn(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"hello"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetAllCmd() + _ = cmd.Flags().Set("format", "keyValue") + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarGetAll(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarGetAll: %v", err) + } + rows := data.(output.OrderedRows) + if rows[0][0].Key != "key" || rows[0][1].Key != "value" { + t.Errorf("keyValue must use key/value columns; got %s/%s", rows[0][0].Key, rows[0][1].Key) + } +} + +func TestConfigEnvvarGetAllEmptyPrintsYellow(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":0,"nodes":[]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetAllCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarGetAll(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarGetAll: %v", err) + } + if data != nil { + t.Errorf("empty case must return nil data; got %+v", data) + } + if !strings.Contains(buf.String(), "There are no environment variables") { + t.Errorf("empty must print Node-parity message; got=%q", buf.String()) + } +} diff --git a/cmd/vip-next/commands/config_envvar_get_test.go b/cmd/vip-next/commands/config_envvar_get_test.go new file mode 100644 index 000000000..c20461e8f --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_get_test.go @@ -0,0 +1,79 @@ +package commands + +import ( + "bytes" + "strings" + "testing" +) + +func TestConfigEnvvarGetFound(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO","value":"hello"},{"name":"BAR","value":"world"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runEnvvarGet(cmd, []string{"FOO"}); err != nil { + t.Fatalf("runEnvvarGet: %v", err) + } + if strings.TrimSpace(buf.String()) != "hello" { + t.Errorf("stdout = %q, want \"hello\"", buf.String()) + } +} + +// TestConfigEnvvarGetLowercaseInputUppercased confirms Node's uppercasing — +// "foo" must resolve to FOO. The stub server returns FOO/BAR regardless of +// query (no per-name filtering server-side, so the assertion is on the +// resolved stdout, not the request). +func TestConfigEnvvarGetLowercaseInputUppercased(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"hello"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runEnvvarGet(cmd, []string{"foo"}); err != nil { + t.Fatalf("runEnvvarGet: %v", err) + } + if strings.TrimSpace(buf.String()) != "hello" { + t.Errorf("lowercase input must be uppercased to FOO; got stdout=%q", buf.String()) + } +} + +func TestConfigEnvvarGetNotFoundIsYellowStdoutExit0(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"hello"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarGetCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runEnvvarGet(cmd, []string{"MISSING"}); err != nil { + t.Fatalf("not-found must NOT error (Node parity); got %v", err) + } + out := buf.String() + // Node uses JSON.stringify which double-quotes the name. Go's %q matches. + if !strings.Contains(out, `"MISSING"`) || !strings.Contains(out, "does not exist") { + t.Errorf("stdout missing Node-parity not-found phrase; got=%q", out) + } +} + +func TestConfigEnvvarGetMissingArgErrors(t *testing.T) { + cmd := ConfigEnvvarGetCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + err := runEnvvarGet(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "Please supply 1 argument") { + t.Errorf("err = %v, want Node-parity required-arg error", err) + } +} diff --git a/cmd/vip-next/commands/config_envvar_list.go b/cmd/vip-next/commands/config_envvar_list.go new file mode 100644 index 000000000..6c6b7a925 --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_list.go @@ -0,0 +1,68 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/envvar" + "github.com/Automattic/vip/internal/output" +) + +// ConfigEnvvarListCmd returns `vip config envvar list`. Wraps the +// GetEnvironmentVariables genqlient query. Empty results print Node's +// "There are no environment variables" yellow message and exit 0; the +// key column varies by format (name | key | id) to match Node parity. +func ConfigEnvvarListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List the names of environment variables", + Long: "List the names of all environment variables on an environment.", + } + addFormatFlagWithShort(cmd) + return buildAppEnvRenderableCmd(cmd, "table", + []string{"table", "csv", "json", "keyValue", "ids"}, runEnvvarList) +} + +func runEnvvarList(cmd *cobra.Command, args []string) (any, error) { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + trackEvent("envvar_list_command_execute", nil) + names, err := envvar.List(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + trackEvent("envvar_list_query_error", map[string]any{"error": err.Error()}) + return nil, err + } + trackEvent("envvar_list_command_success", nil) + if len(names) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), color.YellowString("There are no environment variables")) + return nil, nil + } + keyName := envvarKeyForFormat(cmd) + rows := make(output.OrderedRows, 0, len(names)) + for _, n := range names { + rows = append(rows, output.OrderedRow{{Key: keyName, Value: n}}) + } + return rows, nil +} + +// envvarKeyForFormat mirrors Node's per-format key swap in vip-config-envvar- +// list.js and vip-config-envvar-get-all.js: keyValue -> "key", ids -> "id", +// everything else -> "name". +func envvarKeyForFormat(cmd *cobra.Command) string { + f, _ := cmd.Flags().GetString("format") + switch f { + case "keyValue": + return "key" + case "ids": + return "id" + default: + return "name" + } +} diff --git a/cmd/vip-next/commands/config_envvar_list_test.go b/cmd/vip-next/commands/config_envvar_list_test.go new file mode 100644 index 000000000..514da4c31 --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_list_test.go @@ -0,0 +1,134 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/output" +) + +// envvarStubServer returns a single-response GraphQL stub. The handlers +// fire one query per invocation (List, Get, GetAll), so a constant body is +// enough — only the envvar-with-values shape changes between operations. +func envvarStubServer(_ *testing.T, body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +// setupEnvvarConfig wires SetConfig with a genqlient client pointed at srv. +// Production also wires Tracker + AppCtxConfig; tests don't need either +// because runEnvvarList / runEnvvarGet / runEnvvarGetAll are invoked +// directly (bypassing the WithAppContext + WithEnvContext middleware). +func setupEnvvarConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: c}) +} + +// ctxWithAppEnv returns a context carrying a pre-resolved AppEnv. The +// handlers consume App.ID + Env.ID; everything else can stay zero. +func ctxWithAppEnv(appID, envID int64) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "x"}, + Env: appctx.Env{ID: envID, Name: "develop"}, + }) +} + +func TestConfigEnvvarListReturnsNames(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO"},{"name":"BAR"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarListCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarList(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarList: %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want output.OrderedRows", data) + } + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2 (got %+v)", len(rows), rows) + } + if rows[0][0].Key != "name" || rows[0][0].Value.(string) != "FOO" { + t.Errorf("row[0] = %+v, want name=FOO", rows[0]) + } + if rows[1][0].Value.(string) != "BAR" { + t.Errorf("row[1] value = %v, want BAR", rows[1][0].Value) + } +} + +func TestConfigEnvvarListEmptyPrintsYellow(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":0,"nodes":[]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarListCmd() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarList(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarList: %v", err) + } + if data != nil { + t.Errorf("empty case must return nil data; got %+v", data) + } + if !strings.Contains(buf.String(), "There are no environment variables") { + t.Errorf("empty case must print Node-parity message; got=%q", buf.String()) + } +} + +func TestConfigEnvvarListKeyValueChangesColumn(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarListCmd() + _ = cmd.Flags().Set("format", "keyValue") + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarList(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarList: %v", err) + } + rows := data.(output.OrderedRows) + if rows[0][0].Key != "key" { + t.Errorf("keyValue format must use 'key' column; got %q", rows[0][0].Key) + } +} + +func TestConfigEnvvarListIdsChangesColumn(t *testing.T) { + srv := envvarStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO"}]}}]}}}`) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarListCmd() + _ = cmd.Flags().Set("format", "ids") + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runEnvvarList(cmd, nil) + if err != nil { + t.Fatalf("runEnvvarList: %v", err) + } + rows := data.(output.OrderedRows) + if rows[0][0].Key != "id" { + t.Errorf("ids format must use 'id' column; got %q", rows[0][0].Key) + } +} diff --git a/cmd/vip-next/commands/config_envvar_set.go b/cmd/vip-next/commands/config_envvar_set.go new file mode 100644 index 000000000..7513c757c --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_set.go @@ -0,0 +1,160 @@ +package commands + +import ( + "errors" + "fmt" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/envvar" + "github.com/Automattic/vip/internal/exit" +) + +// ConfigEnvvarSetCmd returns `vip config envvar set `. +// +// Mutation wrapper around addEnvironmentVariable (server-side upsert). +// Production confirms inline because the prompt message interpolates the +// variable name + app name dynamically. +// +// Deferred (interactive-only) behavior — see commit message: +// - promptForReloadManifest after the mutation succeeds. +// - Value-echo confirmation when --from-file is used. +// +// Both are bypassed when --skip-confirmation is set or VIP_NON_INTERACTIVE=1, +// which covers the parity scenarios in M6. +func ConfigEnvvarSetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set ", + Short: "Set the value of an environment variable", + Long: "Add or update an environment variable. The value can be passed via --from-file= or entered at a masked prompt.", + Args: cobra.ExactArgs(1), + } + addAppEnvFlags(cmd) + cmd.Flags().StringP("from-file", "f", "", "Read the value from a file (Node parity: data.trim() strips surrounding whitespace).") + + // vip-config-envvar-set.js registers --from-file then --skip-confirmation, + // so they take -f and -s respectively. + cmd.Flags().BoolP("skip-confirmation", "s", false, "Skip confirmation prompts.") + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithSkipConfirmationFlag(cmd), + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runEnvvarSet) +} + +func runEnvvarSet(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + // Node parity: vip-config-envvar-set.js uppercases + trims args[0] before + // any validation or block check. + name := strings.ToUpper(strings.TrimSpace(args[0])) + + trackEvent("envvar_set_command_execute", map[string]any{"variable_name": name}) + + // Production prod-gate (inline because the message interpolates name + app). + skipConfirm, _ := cmd.Flags().GetBool("skip-confirmation") + if !skipConfirm && ae.Env.Type == "production" { + msg := fmt.Sprintf("Are you sure you want to set the environment variable %s on %s for site %s?", name, formatEnvironment(ae.Env.Type), ae.App.Name) + ok, err := appctx.Confirm(cmd, msg, false) + if errors.Is(err, appctx.ErrNonInteractive) || (err == nil && !ok) { + trackEvent("envvar_set_command_cancelled", nil) + fmt.Fprintln(cmd.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + } + + // Validate name (Node parity: validateName, then NEW_RELIC block). + if err := envvar.ValidateName(name); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), color.RedString(err.Error())) + trackEvent("envvar_set_command_error", map[string]any{"error": "invalid_name"}) + return exit.Handled(err) + } + + // NEW_RELIC_LICENSE_KEY is platform-managed — refuse. + if name == envvar.NewRelicKey { + const blockMsg = "Setting the New Relic key is not permitted. If you want to set your own New Relic key, please contact WordPress VIP support." + fmt.Fprintln(cmd.OutOrStdout(), color.RedString(blockMsg)) + trackEvent("envvar_set_command_error", map[string]any{"error": "new_relic_blocked"}) + return exit.Handled(errors.New(blockMsg)) + } + + // Resolve value: --from-file wins; otherwise masked Secret prompt. + fromFile, _ := cmd.Flags().GetString("from-file") + var value string + if fromFile != "" { + v, err := envvar.ReadFromFile(fromFile) + if err != nil { + trackEvent("envvar_set_command_error", map[string]any{"error": "read_file"}) + return err + } + value = v + } else { + v, err := appctx.Secret(cmd, fmt.Sprintf("Enter the value for %s:", name)) + if errors.Is(err, appctx.ErrNonInteractive) { + trackEvent("envvar_set_command_error", map[string]any{"error": "non_interactive_no_file"}) + return fmt.Errorf("--from-file= is required in non-interactive contexts") + } + if err != nil { + return err + } + value = v + } + + // Value-echo confirm: ONLY on --from-file path, only when not --skip-confirmation. + // Decline branch mirrors the prod-gate's three-branch shape: distinguish + // ErrNonInteractive (silent cancel) and survey error (real failure) so a + // closed-pipe survey crash doesn't masquerade as a user decline. + if fromFile != "" && !skipConfirm { + envvar.EchoValueForConfirm(cmd.OutOrStdout(), value) + ok, err := appctx.Confirm(cmd, "Please confirm the input value above", false) + if errors.Is(err, appctx.ErrNonInteractive) || (err == nil && !ok) { + // Node parity: this code path uses "Command cancelled by user." + // (yellow), unlike the prod-gate decline which uses plain + // "Command cancelled". Both match Node — different code paths, + // different wording per src/lib/envvar/input.ts::cancel(). + fmt.Fprintln(cmd.OutOrStdout(), color.YellowString("Command cancelled by user.")) + trackEvent("envvar_set_user_cancelled_confirmation", nil) + return nil + } + if err != nil { + return err + } + } + + // Node parity (src/bin/vip-config-envvar-set.js): ask whether to apply + // the update now, then pass through to reloadManifest on the mutation + // input. Short-circuits to false on --skip-confirmation / non-interactive. + reloadManifest, _ := envvar.PromptForReloadManifest(cmd, ae.App.TypeId, skipConfirm) + + if err := envvar.Set(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, name, value, reloadManifest); err != nil { + trackEvent("envvar_set_command_error", map[string]any{"error": err.Error()}) + return err + } + + trackEvent("envvar_set_command_success", map[string]any{"variable_name": name}) + fmt.Fprintln(cmd.OutOrStdout(), + color.GreenString(fmt.Sprintf(`Successfully set environment variable "%s"`, name))) + + // Node parity post-success branching: + // reloadManifest=true -> yellow "active and available" + // reloadManifest=false AND interactive (i.e. not --skip-confirmation) + // -> showDeployWarning() reminding the user it won't apply until deploy. + if reloadManifest { + fmt.Fprintln(cmd.OutOrStdout(), + color.YellowString("Environment variable is active and available.")) + } else if !skipConfirm { + envvar.ShowDeployWarning(cmd.OutOrStdout()) + } + return nil +} diff --git a/cmd/vip-next/commands/config_envvar_set_test.go b/cmd/vip-next/commands/config_envvar_set_test.go new file mode 100644 index 000000000..18819f06b --- /dev/null +++ b/cmd/vip-next/commands/config_envvar_set_test.go @@ -0,0 +1,251 @@ +package commands + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +type alreadyPrintedError interface { + AlreadyPrinted() bool +} + +func requireAlreadyPrintedError(t *testing.T, err error) { + t.Helper() + var marked alreadyPrintedError + if !errors.As(err, &marked) || !marked.AlreadyPrinted() { + t.Fatalf("error printed on stdout must be marked to suppress the shared stderr renderer; got %T: %v", err, err) + } +} + +// envvarMutationStub records the most recent body so set/delete tests can +// assert wire-level shape (operationName, name, value, etc.). +type envvarMutationStub struct { + mu sync.Mutex + lastBody string + respBody string +} + +func (s *envvarMutationStub) start(_ *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.lastBody = string(body) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if s.respBody == "" { + _, _ = w.Write([]byte(`{"data":null}`)) + return + } + _, _ = w.Write([]byte(s.respBody)) + })) +} + +func (s *envvarMutationStub) body() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastBody +} + +func TestEnvvarSetFromFileNonProd(t *testing.T) { + stub := &envvarMutationStub{ + respBody: `{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"MY_VAR"}]}}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + // Write the value to a tmp file. + dir := t.TempDir() + valuePath := filepath.Join(dir, "value.txt") + if err := os.WriteFile(valuePath, []byte("hello\n"), 0600); err != nil { + t.Fatalf("write tmp value: %v", err) + } + + cmd := ConfigEnvvarSetCmd() + _ = cmd.Flags().Set("from-file", valuePath) + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarSet(cmd, []string{"my_var"}); err != nil { + t.Fatalf("runEnvvarSet: %v", err) + } + + out := stdout.String() + // Node parity: name is uppercased before being printed; quoted via %s with literal "". + if !strings.Contains(out, `Successfully set environment variable "MY_VAR"`) { + t.Errorf("stdout = %q, want success message with uppercased quoted name", out) + } + body := stub.body() + if !strings.Contains(body, `"operationName":"AddEnvironmentVariable"`) { + t.Errorf("expected AddEnvironmentVariable op; body=%s", body) + } + // Value trimmed of trailing newline. + if !strings.Contains(body, `"value":"hello"`) { + t.Errorf("expected trimmed value=hello; body=%s", body) + } + if !strings.Contains(body, `"name":"MY_VAR"`) { + t.Errorf("expected uppercased name=MY_VAR; body=%s", body) + } +} + +func TestEnvvarSetBlocksNewRelicKey(t *testing.T) { + // Server should NOT be hit for this case — the block check fires before + // any mutation. Use a server that fails the test if called. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + // ResolveApp may be called by the middleware in production, but in + // these unit tests we bypass middleware and pre-populate ctx — so a + // hit here is unexpected and worth surfacing. + t.Errorf("unexpected request to mock server: %s", body) + _, _ = w.Write([]byte(`{"data":null}`)) + })) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cases := []string{"NEW_RELIC_LICENSE_KEY", "new_relic_license_key"} + for _, n := range cases { + t.Run(n, func(t *testing.T) { + cmd := ConfigEnvvarSetCmd() + _ = cmd.Flags().Set("from-file", "/dev/null") + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + err := runEnvvarSet(cmd, []string{n}) + if err == nil { + t.Errorf("expected error blocking %s, got nil", n) + } + requireAlreadyPrintedError(t, err) + if !strings.Contains(stdout.String(), "New Relic") { + t.Errorf("stdout must mention 'New Relic'; got %q", stdout.String()) + } + }) + } +} + +// TestEnvvarSetPassesReloadManifestFalseWhenSkipConfirmation pins the +// wire-level shape: --skip-confirmation short-circuits the prompt to false +// and that value is forwarded to the mutation input. +func TestEnvvarSetPassesReloadManifestFalseWhenSkipConfirmation(t *testing.T) { + stub := &envvarMutationStub{ + respBody: `{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"MY_VAR"}]}}}}`, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + dir := t.TempDir() + valuePath := filepath.Join(dir, "v.txt") + if err := os.WriteFile(valuePath, []byte("x"), 0600); err != nil { + t.Fatalf("write tmp value: %v", err) + } + + cmd := ConfigEnvvarSetCmd() + _ = cmd.Flags().Set("from-file", valuePath) + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarSet(cmd, []string{"FOO"}); err != nil { + t.Fatalf("runEnvvarSet: %v", err) + } + if !strings.Contains(stub.body(), `"reloadManifest":false`) { + t.Errorf("mutation body must include reloadManifest:false on --skip-confirmation; body=%s", stub.body()) + } + // --skip-confirmation must also suppress the post-success deploy warning. + if strings.Contains(stdout.String(), "Important:") { + t.Errorf("ShowDeployWarning must NOT fire under --skip-confirmation; stdout=%q", stdout.String()) + } +} + +// TestEnvvarSetValueEchoDeclineCancels covers the Task 3 value-echo gate: +// --from-file present, --skip-confirmation absent → handler echoes value +// + prompts → VIP_NON_INTERACTIVE makes Confirm return ErrNonInteractive +// → handler treats as decline → yellow "Command cancelled by user." + +// exit 0, mutation must NOT fire. +func TestEnvvarSetValueEchoDeclineCancels(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + stub := &envvarMutationStub{respBody: `{"data":null}`} + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + dir := t.TempDir() + p := filepath.Join(dir, "v.txt") + if err := os.WriteFile(p, []byte("secret"), 0600); err != nil { + t.Fatalf("write tmp value: %v", err) + } + + cmd := ConfigEnvvarSetCmd() + _ = cmd.Flags().Set("from-file", p) // NOT skip-confirmation + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runEnvvarSet(cmd, []string{"FOO"}); err != nil { + t.Fatalf("expected nil (clean cancel); got %v", err) + } + if !strings.Contains(stdout.String(), "Command cancelled by user.") { + t.Errorf("expected Node-parity cancel wording; got %q", stdout.String()) + } + if strings.Contains(stub.body(), "AddEnvironmentVariable") { + t.Errorf("mutation must NOT fire on value-confirm decline; body=%s", stub.body()) + } + // Echo banners must have been printed (both opening + closing — the + // closing banner pins that EchoValueForConfirm ran to completion). + if !strings.Contains(stdout.String(), "===== Received value printed below =====") { + t.Errorf("value-echo opening banner missing; got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "===== Received value printed above =====") { + t.Errorf("value-echo closing banner missing; got %q", stdout.String()) + } +} + +func TestEnvvarSetInvalidName(t *testing.T) { + stub := &envvarMutationStub{respBody: `{"data":null}`} + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigEnvvarSetCmd() + _ = cmd.Flags().Set("from-file", "/dev/null") + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + err := runEnvvarSet(cmd, []string{"bad-name-with-dash"}) + if err == nil { + t.Fatal("expected error for invalid name, got nil") + } + requireAlreadyPrintedError(t, err) + if !strings.Contains(stdout.String(), "A-Z, 0-9, or _") { + t.Errorf("stdout must include Node-parity error text; got %q", stdout.String()) + } + // Mutation must NOT have been called for an invalid name. + if strings.Contains(stub.body(), `"operationName":"AddEnvironmentVariable"`) { + t.Errorf("mutation must not fire on invalid name; body=%s", stub.body()) + } +} diff --git a/cmd/vip-next/commands/config_software.go b/cmd/vip-next/commands/config_software.go new file mode 100644 index 000000000..63432cc01 --- /dev/null +++ b/cmd/vip-next/commands/config_software.go @@ -0,0 +1,229 @@ +package commands + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/output" + "github.com/Automattic/vip/internal/softwaresettings" +) + +// ConfigSoftwareCmd returns the `vip config software` parent. Subcommands +// (`get` now, `update` in Task 7) are attached here. +func ConfigSoftwareCmd() *cobra.Command { + parent := &cobra.Command{ + Use: "software", + Short: "Manage software settings for an environment", + Long: "Manage software settings (WordPress, PHP, Node.js, MU Plugins) for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + parent.AddCommand(ConfigSoftwareGetCmd()) + parent.AddCommand(ConfigSoftwareUpdateCmd()) + return parent +} + +// ConfigSoftwareGetCmd returns the `vip config software get` leaf command. +func ConfigSoftwareGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get [wordpress|php|nodejs|muplugins]", + Short: "Retrieve software settings for an environment", + Long: "Retrieve software settings for a VIP Platform environment. " + + "Optionally filter to a single component by passing its slug as a positional argument.", + Args: cobra.MaximumNArgs(1), + } + // Register --include before buildAppEnvRenderableCmd wraps the cmd so + // cobra can parse it before RunE fires. + cmd.Flags().StringP("format", "f", "table", + "Render output in a particular format.") + cmd.Flags().StringArrayP("include", "i", + nil, + `Retrieve additional data of a specific type. Supported values: available_versions`) + return buildAppEnvRenderableCmd(cmd, "table", []string{"table", "csv", "json"}, runConfigSoftwareGet) +} + +// validIncludes is the set of --include values accepted by `config software get`. +var validIncludes = map[string]bool{ + "available_versions": true, +} + +func runConfigSoftwareGet(cmd *cobra.Command, args []string) (any, error) { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, errors.New("appctx not set; this is a wiring bug") + } + + cfg := GetConfig() + trackEvent("config_software_get_execute", map[string]any{"args": args}) + + // Validate --include values before hitting the network. + includes, _ := cmd.Flags().GetStringArray("include") + var invalid []string + for _, inc := range includes { + if !validIncludes[inc] { + invalid = append(invalid, inc) + } + } + if len(invalid) > 0 { + return nil, errors.New("Invalid include value(s): " + strings.Join(invalid, ",")) + } + + // Fetch software settings from GraphQL. + resp, err := gql.SoftwareSettings(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return nil, err + } + + // Navigate to the environment's softwareSettings. + if resp.App == nil || len(resp.App.Environments) == 0 { + return nil, errors.New("Software settings are not supported for this environment.") + } + env := resp.App.Environments[0] + ss := env.SoftwareSettings + if ss == nil { + return nil, errors.New("Software settings are not supported for this environment.") + } + + // Determine which components to render. Node's order: wordpress, php, + // muplugins, nodejs (vip-config-software-get.js:95-100). + type entry struct { + slug string + node gqlSoftwareNode + } + allComponents := []entry{ + {"wordpress", gqlNodeFrom(ss.Wordpress)}, + {"php", gqlNodeFrom(ss.Php)}, + {"muplugins", gqlNodeFrom(ss.Muplugins)}, + {"nodejs", gqlNodeFrom(ss.Nodejs)}, + } + + var chosen []entry + if len(args) > 0 { + component := args[0] + var found *entry + for _, e := range allComponents { + if e.slug == component && e.node != nil { + e := e + found = &e + break + } + } + if found == nil { + return nil, errors.New("Software settings for " + component + " are not supported for this environment.") + } + chosen = []entry{*found} + } else { + for _, e := range allComponents { + if e.node != nil { + chosen = append(chosen, e) + } + } + } + + // Determine output format from the --format flag (set by WithFormat). + format, _ := cmd.Flags().GetString("format") + + // Build output rows. + var rows output.OrderedRows + for _, e := range chosen { + sw := gqlNodeToSoftware(e.node) + var row softwaresettings.FormattedRow + if format == "json" { + row = softwaresettings.FormatSettingJSON(sw, includes) + } else { + row = softwaresettings.FormatSetting(sw, includes, format) + } + orderedRow := output.OrderedRow{ + {Key: "name", Value: row.Name}, + {Key: "slug", Value: row.Slug}, + {Key: "version", Value: row.Version}, + } + if row.AvailableVersions != nil { + orderedRow = append(orderedRow, output.Cell{Key: "available_versions", Value: row.AvailableVersions}) + } + rows = append(rows, orderedRow) + } + + trackEvent("config_software_get_success", map[string]any{"args": args}) + return rows, nil +} + +// gqlSoftwareNode is an interface satisfied by all four generated software +// setting types (wordpress/php/muplugins/nodejs). It mirrors the SoftwareNode +// embedded fragment accessors. +type gqlSoftwareNode interface { + GetName() string + GetSlug() string + GetPinned() bool + GetCurrent() *gql.SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion + GetOptions() []*gql.SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion +} + +// gqlNodeFrom coerces a concrete type to the interface. Returns nil when the +// argument is nil (genqlient pointer receivers are safe to call on nil, but +// the interface assertion for nil-pointer is non-nil, so we check explicitly). +func gqlNodeFrom[T gqlSoftwareNode](v T) gqlSoftwareNode { + // A nil pointer stored in a concrete type satisfies the interface as + // non-nil. We detect that case by checking whether the pointer value is + // actually zero. + if any(v) == nil { + return nil + } + // Use reflect-free nil check via the any→pointer trick. + type nilChecker interface{ GetName() string } + // All gqlSoftwareNode implementations are pointers; if the underlying + // pointer is nil, GetName would panic. Use a type-switch nil check. + switch t := any(v).(type) { + case *gql.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware: + if t == nil { + return nil + } + case *gql.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware: + if t == nil { + return nil + } + case *gql.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware: + if t == nil { + return nil + } + case *gql.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware: + if t == nil { + return nil + } + } + return v +} + +// gqlNodeToSoftware converts a gqlSoftwareNode to the pure-logic Software type. +func gqlNodeToSoftware(n gqlSoftwareNode) softwaresettings.Software { + sw := softwaresettings.Software{ + Name: n.GetName(), + Slug: n.GetSlug(), + Pinned: n.GetPinned(), + } + if cur := n.GetCurrent(); cur != nil { + sw.Current = softwaresettings.Version{ + Version: cur.Version, + Default: cur.Default, + Deprecated: cur.Deprecated, + Unstable: cur.Unstable, + } + } + for _, opt := range n.GetOptions() { + if opt == nil { + continue + } + sw.Options = append(sw.Options, softwaresettings.Version{ + Version: opt.Version, + Default: opt.Default, + Deprecated: opt.Deprecated, + Unstable: opt.Unstable, + }) + } + return sw +} diff --git a/cmd/vip-next/commands/config_software_test.go b/cmd/vip-next/commands/config_software_test.go new file mode 100644 index 000000000..a1c4043b8 --- /dev/null +++ b/cmd/vip-next/commands/config_software_test.go @@ -0,0 +1,161 @@ +package commands + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/output" +) + +// softwareSettingsBody builds a SoftwareSettings GraphQL JSON response for a +// WordPress environment (typeId 2) with WordPress and PHP populated. +const softwareSettingsBody = `{"data":{"app":{"id":1,"name":"testapp","typeId":2,"environments":[{"id":2,"appId":1,"type":"develop","name":"develop","softwareSettings":{"wordpress":{"name":"WordPress","slug":"wordpress","pinned":false,"current":{"version":"6.4","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},"options":[{"version":"6.3","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},{"version":"6.4","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false}]},"php":{"name":"PHP","slug":"php","pinned":true,"current":{"version":"8.2","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"8.2","private":false},"options":[{"version":"8.1","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"8.2","private":false},{"version":"8.2","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"8.2","private":false}]},"muplugins":null,"nodejs":null}}]}}}` + +// softwareSettingsNullBody returns a response where softwareSettings is null +// (environment does not support it). +const softwareSettingsNullBody = `{"data":{"app":{"id":1,"name":"testapp","typeId":2,"environments":[{"id":2,"appId":1,"type":"develop","name":"develop","softwareSettings":null}]}}}` + +func setupSoftwareConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: c}) +} + +func softwareStubServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) // drain + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestConfigSoftwareGetAllComponents(t *testing.T) { + t.Setenv("NO_COLOR", "1") + srv := softwareStubServer(t, softwareSettingsBody) + setupSoftwareConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareGetCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runConfigSoftwareGet(cmd, nil) + if err != nil { + t.Fatalf("runConfigSoftwareGet: %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want OrderedRows", data) + } + // wordpress + php should be present (muplugins and nodejs are null) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + // First column of first row should be name = WordPress + found := false + for _, row := range rows { + for _, col := range row { + if col.Key == "name" && col.Value.(string) == "WordPress" { + found = true + } + } + } + if !found { + t.Errorf("expected WordPress row in output; got %+v", rows) + } + // PHP row + found = false + for _, row := range rows { + for _, col := range row { + if col.Key == "name" && col.Value.(string) == "PHP" { + found = true + } + } + } + if !found { + t.Errorf("expected PHP row in output; got %+v", rows) + } +} + +func TestConfigSoftwareGetSingleComponent(t *testing.T) { + t.Setenv("NO_COLOR", "1") + srv := softwareStubServer(t, softwareSettingsBody) + setupSoftwareConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareGetCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + data, err := runConfigSoftwareGet(cmd, []string{"wordpress"}) + if err != nil { + t.Fatalf("runConfigSoftwareGet(wordpress): %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want OrderedRows", data) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0][0].Value.(string) != "WordPress" { + t.Errorf("row[0].name = %v, want WordPress", rows[0][0].Value) + } +} + +func TestConfigSoftwareGetInvalidInclude(t *testing.T) { + srv := softwareStubServer(t, softwareSettingsBody) + setupSoftwareConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareGetCmd() + _ = cmd.Flags().Set("include", "bogus") + cmd.SetContext(ctxWithAppEnv(1, 2)) + + _, err := runConfigSoftwareGet(cmd, nil) + if err == nil { + t.Fatal("expected error for invalid include, got nil") + } + if !strings.Contains(err.Error(), "Invalid include value(s): bogus") { + t.Errorf("err = %q, want 'Invalid include value(s): bogus'", err.Error()) + } +} + +func TestConfigSoftwareGetNullSettings(t *testing.T) { + srv := softwareStubServer(t, softwareSettingsNullBody) + setupSoftwareConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareGetCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + _, err := runConfigSoftwareGet(cmd, nil) + if err == nil { + t.Fatal("expected error for null softwareSettings, got nil") + } + if err.Error() != "Software settings are not supported for this environment." { + t.Errorf("err = %q, want Node-parity message", err.Error()) + } +} + +func TestConfigSoftwareGetUnknownComponent(t *testing.T) { + srv := softwareStubServer(t, softwareSettingsBody) + setupSoftwareConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareGetCmd() + cmd.SetContext(ctxWithAppEnv(1, 2)) + + _, err := runConfigSoftwareGet(cmd, []string{"redis"}) + if err == nil { + t.Fatal("expected error for unsupported component, got nil") + } + if err.Error() != "Software settings for redis are not supported for this environment." { + t.Errorf("err = %q, want Node-parity message", err.Error()) + } +} diff --git a/cmd/vip-next/commands/config_software_update.go b/cmd/vip-next/commands/config_software_update.go new file mode 100644 index 000000000..14e816f13 --- /dev/null +++ b/cmd/vip-next/commands/config_software_update.go @@ -0,0 +1,259 @@ +package commands + +// ConfigSoftwareUpdateCmd implements `vip config software update `. +// +// Interactive-args deviation (intentional): Node prompts (Select) when component +// or version are omitted. vip-next requires both as positional args: this keeps +// the CLI scriptable and matches documented usage. On a single-component app the +// component arg is still required for predictability. Multi-component apps without +// a component get a "Please specify a component" error. +// +// Node parity source: src/bin/vip-config-software-update.js + src/lib/config/software.ts. + +import ( + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/softwaresettings" +) + +// softwareUpdatePollInterval is the delay between SoftwareUpdateJob polls. +// Injectable via tests (set to a short duration). +var softwareUpdatePollInterval = 5 * time.Second + +// ConfigSoftwareUpdateCmd returns the `vip config software update` leaf command. +func ConfigSoftwareUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update software settings for an environment", + Long: "Update a software component (wordpress, php, muplugins, nodejs) to the " + + "specified version for a VIP Platform environment.\n\n" + + "Note: both and are required positional arguments. " + + "Node.js apps support only the 'nodejs' component; WordPress apps support " + + "'wordpress', 'php', and 'muplugins'.", + Args: cobra.ExactArgs(2), + } + cmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + return buildAppEnvCmd(cmd, runConfigSoftwareUpdate) +} + +func runConfigSoftwareUpdate(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + + component := args[0] + version := args[1] + + cfg := GetConfig() + trackEvent("config_software_update_execute", map[string]any{ + "component": component, + "version": version, + }) + + // Fetch software settings for version validation. + resp, err := gql.SoftwareSettings(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + if resp.App == nil || len(resp.App.Environments) == 0 { + return errors.New("Software settings are not supported for this environment.") + } + env := resp.App.Environments[0] + ss := env.SoftwareSettings + if ss == nil { + return errors.New("Software settings are not supported for this environment.") + } + + // Validate component against app type. + resolvedComponent, err := softwaresettings.ResolveComponent(ae.App.TypeId, component) + if err != nil { + return err + } + + // Find the gql node for the resolved component and convert to Software. + type entry struct { + slug string + node gqlSoftwareNode + } + allComponents := []entry{ + {"wordpress", gqlNodeFrom(ss.Wordpress)}, + {"php", gqlNodeFrom(ss.Php)}, + {"muplugins", gqlNodeFrom(ss.Muplugins)}, + {"nodejs", gqlNodeFrom(ss.Nodejs)}, + } + var setting softwaresettings.Software + for _, e := range allComponents { + if e.slug == resolvedComponent && e.node != nil { + setting = gqlNodeToSoftware(e.node) + break + } + } + + // Validate version against allowed options. + resolvedVersion, err := softwaresettings.ResolveVersion(setting, resolvedComponent, version) + if err != nil { + return err + } + + // Confirm unless --yes is set. + // + // A declined (or unanswerable) confirm is a FAILURE here, not a quiet no-op. + // Node's promptForUpdate throws `UserError( 'Update canceled' )` + // (software.ts:335) and command.js routes UserError to exit.withError → + // exit 1. This is the one place in the CLI where Node does not use the + // `console.log('Command cancelled'); process.exit()` (exit 0) convention it + // uses for envvar set/delete and `vip wp`'s production gate — so returning + // nil here made a CI run that forgot --yes report a successful update it + // never performed. + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + msg := fmt.Sprintf("Are you sure you want to upgrade %s to %s?", + softwaresettings.ComponentDisplayName(resolvedComponent), resolvedVersion) + ok, confirmErr := appctx.Confirm(cmd, msg, false) + switch { + case errors.Is(confirmErr, appctx.ErrNonInteractive), confirmErr == nil && !ok: + return errors.New("Update canceled") + case confirmErr != nil: + return errors.New("Command cancelled by user.") + } + } + + // Fire the mutation. + _, err = gql.UpdateSoftwareSettings(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, resolvedComponent, resolvedVersion) + if err != nil { + trackEvent("config_software_update_error", map[string]any{"error": err.Error()}) + return err + } + + trackEvent("config_software_update_mutation_success", nil) + + // Poll until the update job completes. + if err := pollSoftwareUpdateJob(cmd, cfg, ae); err != nil { + trackEvent("config_software_update_poll_error", map[string]any{"error": err.Error()}) + return err + } + + trackEvent("config_software_update_success", map[string]any{ + "component": resolvedComponent, + "version": resolvedVersion, + }) + fmt.Fprintf(cmd.OutOrStdout(), "Successfully updated %s to %s.\n", + softwaresettings.ComponentDisplayName(resolvedComponent), resolvedVersion) + return nil +} + +// jobIface is a convenience alias for the genqlient pointer-to-interface +// element type that GetJobs() returns. +type jobIface = gql.SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface + +// pollSoftwareUpdateJob polls SoftwareUpdateJob until the update completes or +// fails. It mirrors getUpdateResult/_getCompletedJob in software.ts: +// - picks the latest job by createdAt +// - while inProgressLock → keep polling +// - success when no job or progress.status == "success" +// - failure: find a step with status "failed" → "Failed during step: " else "Software update failed" +func pollSoftwareUpdateJob(cmd *cobra.Command, cfg Config, ae *appctx.AppEnv) error { + for { + resp, err := gql.SoftwareUpdateJob(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + + // Navigate to jobs. + if resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + // No environment data → treat as success (job gone). + return nil + } + jobs := resp.App.Environments[0].GetJobs() + if len(jobs) == 0 { + // No jobs → update complete (Node parity: "no job" = success). + return nil + } + + // Pick the latest job by createdAt (string ISO8601 comparison works + // lexicographically for same-timezone values, matching Node behaviour). + // jobs elements are *jobIface (pointer-to-interface); dereference to call methods. + latestPtr := jobs[0] + for _, jPtr := range jobs[1:] { + if jPtr == nil || *jPtr == nil { + continue + } + latestCA := "" + if latestPtr != nil && *latestPtr != nil { + if ca := (*latestPtr).GetCreatedAt(); ca != nil { + latestCA = *ca + } + } + jCA := "" + if ca := (*jPtr).GetCreatedAt(); ca != nil { + jCA = *ca + } + if jCA > latestCA { + latestPtr = jPtr + } + } + if latestPtr == nil || *latestPtr == nil { + return nil + } + latest := *latestPtr + + // Still in-progress → wait and retry. + if lock := latest.GetInProgressLock(); lock != nil && *lock { + time.Sleep(softwareUpdatePollInterval) + continue + } + + // Terminal: inspect progress. + // + // Node's success test is exactly `! completedJob || progress?.status + // === 'success'` (software.ts:398) — a missing progress object and an + // empty status are NOT success, they fall through to the error branch. + // vip-next used to short-circuit both to success, so an update whose + // job never reported a result printed "Successfully updated" and exited + // 0 (silent no-op in CI). + prog := latest.GetProgress() + status := "" + if prog != nil && prog.Status != nil { + status = *prog.Status + } + if status == "success" { + return nil + } + // DIVERGENCE, deliberately left as-is (out of scope for the exit-code + // remediation): Node treats any other value as terminal failure, while + // vip-next keeps polling a non-terminal status such as "running" that + // arrives with inProgressLock=false. That is a hang risk, not a silent + // success — and this loop has no 6 h poll.Timeout ceiling either. + if status != "" && status != "failed" { + time.Sleep(softwareUpdatePollInterval) + continue + } + // Node looks for a failed step regardless of the top-level status. + if prog != nil { + for _, step := range prog.Steps { + if step == nil { + continue + } + stepStatus := "" + if step.Status != nil { + stepStatus = *step.Status + } + if stepStatus == "failed" { + name := "" + if step.Name != nil { + name = *step.Name + } + return errors.New("Failed during step: " + name) + } + } + } + return errors.New("Software update failed") + } +} diff --git a/cmd/vip-next/commands/config_software_update_test.go b/cmd/vip-next/commands/config_software_update_test.go new file mode 100644 index 000000000..ab581065e --- /dev/null +++ b/cmd/vip-next/commands/config_software_update_test.go @@ -0,0 +1,334 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/appctx" +) + +// ctxWithAppEnvTyped builds a context with a known TypeId (for update command +// tests that branch on app type). +func ctxWithAppEnvTyped(appID, envID int64, typeID int64) *appctx.AppEnv { + return &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "testapp", TypeId: typeID}, + Env: appctx.Env{ID: envID, Name: "develop"}, + } +} + +// softwareUpdateSequence is a multi-response stub that serves responses in +// order (first call → responses[0], second → responses[1], …, last repeated). +type softwareUpdateSequence struct { + mu sync.Mutex + responses []string + idx int + bodies []string +} + +func (s *softwareUpdateSequence) start(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.bodies = append(s.bodies, string(body)) + resp := s.responses[s.idx] + if s.idx < len(s.responses)-1 { + s.idx++ + } + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(resp)) + })) + t.Cleanup(srv.Close) + return srv +} + +func (s *softwareUpdateSequence) allBodies() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.bodies)) + copy(out, s.bodies) + return out +} + +func (s *softwareUpdateSequence) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.bodies) +} + +// softwareUpdateSettingsBody is a SoftwareSettings response for a WP env (typeId 2) +// with wordpress options that include "managed_latest" and "6.4". +const softwareUpdateSettingsBody = `{"data":{"app":{"id":1,"name":"testapp","typeId":2,"environments":[{"id":2,"appId":1,"type":"develop","name":"develop","softwareSettings":{"wordpress":{"name":"WordPress","slug":"wordpress","pinned":false,"current":{"version":"6.3","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},"options":[{"version":"6.3","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},{"version":"6.4","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false}]},"php":null,"muplugins":null,"nodejs":null}}]}}}` + +const softwareUpdateMutationOKBody = `{"data":{"updateSoftwareSettings":{"wordpress":null,"php":null,"muplugins":null,"nodejs":null}}}` + +// jobInProgress is the first poll response: inProgressLock=true, no terminal status. +const jobInProgress = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":null,"createdAt":"2024-01-01T00:00:00Z","inProgressLock":true,"progress":{"status":"running","steps":[]}}]}]}}}` + +// jobSuccess is the second poll response: success. +const jobSuccess = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":{"status":"success","steps":[]}}]}]}}}` + +// jobFailed has a step with status="failed". +const jobFailed = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":{"status":"failed","steps":[{"step":"apply","name":"Apply","status":"failed"}]}}]}]}}}` + +func setupSoftwareUpdateConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: c}) +} + +// TestConfigSoftwareUpdateYesSkipsConfirmAndPollsToSuccess covers the happy +// path: --yes flag, mutation fires, poll transitions to success, exit 0. +func TestConfigSoftwareUpdateYesSkipsConfirmAndPollsToSuccess(t *testing.T) { + t.Setenv("NO_COLOR", "1") + // Response sequence: 1) SoftwareSettings (for validation), 2) UpdateSoftwareSettings mutation, + // 3) first SoftwareUpdateJob poll (in progress), 4) second poll (success). + seq := &softwareUpdateSequence{ + responses: []string{ + softwareUpdateSettingsBody, + softwareUpdateMutationOKBody, + jobInProgress, + jobSuccess, + }, + } + srv := seq.start(t) + setupSoftwareUpdateConfig(srv) + defer SetConfig(Config{}) + + // Make poll interval very short for tests. + origInterval := softwareUpdatePollInterval + softwareUpdatePollInterval = 10 * time.Millisecond + defer func() { softwareUpdatePollInterval = origInterval }() + + cmd := ConfigSoftwareUpdateCmd() + _ = cmd.Flags().Set("yes", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + ae := ctxWithAppEnvTyped(1, 2, 2) + cmd.SetContext(appctx.WithAppEnv(ctxWithAppEnv(1, 2), ae)) + + if err := runConfigSoftwareUpdate(cmd, []string{"wordpress", "6.4"}); err != nil { + t.Fatalf("runConfigSoftwareUpdate: %v", err) + } + + bodies := seq.allBodies() + // Must have fired at least 4 calls: settings + mutation + 2 polls + if len(bodies) < 4 { + t.Errorf("expected at least 4 GQL calls, got %d", len(bodies)) + } + // Mutation must have been called. + found := false + for _, b := range bodies { + if strings.Contains(b, "UpdateSoftwareSettings") { + found = true + } + } + if !found { + t.Errorf("UpdateSoftwareSettings mutation was not fired; bodies=%v", bodies) + } + // Success output. + if !strings.Contains(stdout.String(), "success") && !strings.Contains(stdout.String(), "updated") && + !strings.Contains(stdout.String(), "Success") && !strings.Contains(stdout.String(), "complete") { + t.Errorf("expected success message in stdout; got %q", stdout.String()) + } +} + +// TestConfigSoftwareUpdatePollFailed covers: poll returns a failed step → error message. +func TestConfigSoftwareUpdatePollFailed(t *testing.T) { + t.Setenv("NO_COLOR", "1") + seq := &softwareUpdateSequence{ + responses: []string{ + softwareUpdateSettingsBody, + softwareUpdateMutationOKBody, + jobFailed, + }, + } + srv := seq.start(t) + setupSoftwareUpdateConfig(srv) + defer SetConfig(Config{}) + + origInterval := softwareUpdatePollInterval + softwareUpdatePollInterval = 10 * time.Millisecond + defer func() { softwareUpdatePollInterval = origInterval }() + + cmd := ConfigSoftwareUpdateCmd() + _ = cmd.Flags().Set("yes", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + ae := ctxWithAppEnvTyped(1, 2, 2) + cmd.SetContext(appctx.WithAppEnv(ctxWithAppEnv(1, 2), ae)) + + err := runConfigSoftwareUpdate(cmd, []string{"wordpress", "6.4"}) + if err == nil { + t.Fatal("expected error on failed poll, got nil") + } + if !strings.Contains(err.Error(), "Failed during step: Apply") { + t.Errorf("err = %q, want 'Failed during step: Apply'", err.Error()) + } +} + +// TestConfigSoftwareUpdateConfirmDecline: VIP_NON_INTERACTIVE=1 + no --yes → +// Confirm returns ErrNonInteractive → the update does not happen, so the +// command must FAIL. +// +// Node throws `UserError( 'Update canceled' )` from promptForUpdate +// (src/lib/config/software.ts:335); the bin re-throws it +// (src/bin/vip-config-software-update.js:142) and command.js's +// unhandledRejection handler routes a UserError to exit.withError +// (src/lib/cli/command.js:27-28 → src/lib/cli/exit.ts `process.exit( 1 )`). +// vip-next printed "Update canceled" and returned nil, so a CI job that forgot +// --yes reported a green software update that never left the machine. +func TestConfigSoftwareUpdateConfirmDecline(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_NON_INTERACTIVE", "1") + seq := &softwareUpdateSequence{ + // SoftwareSettings is fetched for validation; mutation must NOT fire. + responses: []string{ + softwareUpdateSettingsBody, + `{"data":null}`, + }, + } + srv := seq.start(t) + setupSoftwareUpdateConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareUpdateCmd() + // no --yes + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + ae := ctxWithAppEnvTyped(1, 2, 2) + cmd.SetContext(appctx.WithAppEnv(ctxWithAppEnv(1, 2), ae)) + + err := runConfigSoftwareUpdate(cmd, []string{"wordpress", "6.4"}) + if err == nil { + t.Fatal("a declined/non-interactive confirm must fail (Node exits 1)") + } + if !strings.Contains(err.Error(), "Update canceled") { + t.Errorf("err = %q, want Node's 'Update canceled'", err) + } + if strings.Contains(stdout.String(), "Successfully updated") { + t.Errorf("stdout must not claim success; got %q", stdout.String()) + } + // Mutation must NOT have fired. + for _, b := range seq.allBodies() { + if strings.Contains(b, "UpdateSoftwareSettings") { + t.Errorf("mutation must NOT fire on decline; body=%s", b) + } + } +} + +// jobNoProgress: terminal job (inProgressLock=false) that carries no progress +// object at all. +const jobNoProgress = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":null}]}]}}}` + +// jobEmptyStatus: terminal job whose progress.status is the empty string. +const jobEmptyStatus = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":{"status":"","steps":[]}}]}]}}}` + +// jobEmptyStatusFailedStep: no top-level status, but a step reports failure. +const jobEmptyStatusFailedStep = `{"data":{"app":{"id":1,"environments":[{"id":2,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":{"status":null,"steps":[{"step":"apply","name":"Apply","status":"failed"}]}}]}]}}}` + +// Node's getUpdateResult treats a terminal job as successful ONLY when there is +// no job at all or `progress.status === 'success'` +// (src/lib/config/software.ts:398). Anything else — including a null progress +// object or an empty status string — falls through to `ok: false` and the bin +// throws it (vip-config-software-update.js:116), i.e. exit 1. +// +// vip-next short-circuited `""`/nil to success, so an update whose job never +// reported a result printed "Successfully updated …" and exited 0. +func TestConfigSoftwareUpdateEmptyJobProgressIsNotSuccess(t *testing.T) { + cases := []struct { + name string + job string + wantErr string + }{ + {"nil progress", jobNoProgress, "Software update failed"}, + {"empty status", jobEmptyStatus, "Software update failed"}, + {"empty status with failed step", jobEmptyStatusFailedStep, "Failed during step: Apply"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + seq := &softwareUpdateSequence{ + responses: []string{ + softwareUpdateSettingsBody, + softwareUpdateMutationOKBody, + tc.job, + }, + } + srv := seq.start(t) + setupSoftwareUpdateConfig(srv) + defer SetConfig(Config{}) + + origInterval := softwareUpdatePollInterval + softwareUpdatePollInterval = 10 * time.Millisecond + defer func() { softwareUpdatePollInterval = origInterval }() + + cmd := ConfigSoftwareUpdateCmd() + _ = cmd.Flags().Set("yes", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + ae := ctxWithAppEnvTyped(1, 2, 2) + cmd.SetContext(appctx.WithAppEnv(ctxWithAppEnv(1, 2), ae)) + + err := runConfigSoftwareUpdate(cmd, []string{"wordpress", "6.4"}) + if err == nil { + t.Fatal("a job that never reported success must fail (Node exits 1)") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("err = %q, want %q", err, tc.wantErr) + } + if strings.Contains(stdout.String(), "Successfully updated") { + t.Errorf("stdout must not claim success; got %q", stdout.String()) + } + }) + } +} + +// TestConfigSoftwareUpdateInvalidComponent: passing unsupported component → +// validation error, no network calls for mutation. +func TestConfigSoftwareUpdateInvalidComponent(t *testing.T) { + t.Setenv("NO_COLOR", "1") + seq := &softwareUpdateSequence{ + responses: []string{ + softwareUpdateSettingsBody, + `{"data":null}`, + }, + } + srv := seq.start(t) + setupSoftwareUpdateConfig(srv) + defer SetConfig(Config{}) + + cmd := ConfigSoftwareUpdateCmd() + _ = cmd.Flags().Set("yes", "true") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + ae := ctxWithAppEnvTyped(1, 2, 2) + cmd.SetContext(appctx.WithAppEnv(ctxWithAppEnv(1, 2), ae)) + + err := runConfigSoftwareUpdate(cmd, []string{"redis", "1.0"}) + if err == nil { + t.Fatal("expected error for invalid component, got nil") + } + if !strings.Contains(err.Error(), "Component redis is not supported. Use one of: wordpress,php,muplugins") { + t.Errorf("err = %q, want component-not-supported message", err.Error()) + } + // Mutation must NOT have fired. + for _, b := range seq.allBodies() { + if strings.Contains(b, "UpdateSoftwareSettings") { + t.Errorf("mutation must NOT fire on invalid component; body=%s", b) + } + } +} diff --git a/cmd/vip-next/commands/db.go b/cmd/vip-next/commands/db.go new file mode 100644 index 000000000..59caeb8d3 --- /dev/null +++ b/cmd/vip-next/commands/db.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/spf13/cobra" + +// DBCmd returns the `vip db` parent. Leaf subcommands attach in root.go; +// the parent itself does nothing on its own (cobra prints help by default). +func DBCmd() *cobra.Command { + return &cobra.Command{ + Use: "db", + Short: "Database operations", + Long: "Database operations for a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/db_phpmyadmin.go b/cmd/vip-next/commands/db_phpmyadmin.go new file mode 100644 index 000000000..2e988f809 --- /dev/null +++ b/cmd/vip-next/commands/db_phpmyadmin.go @@ -0,0 +1,122 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/fatih/color" + "github.com/pkg/browser" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/phpmyadmin" +) + +// openURLFn is a package-level seam so tests can avoid actually opening +// the user's browser when exercising the default (non --print) path. +// Production wires browser.OpenURL. +var openURLFn = browser.OpenURL + +// DBPhpmyadminCmd returns `vip db phpmyadmin`. Node parity: +// src/bin/vip-db-phpmyadmin.ts + src/commands/phpmyadmin.ts. The actual +// three-op enable+poll+generate flow lives in internal/phpmyadmin; this +// command just resolves --print / --silent and dispatches. +func DBPhpmyadminCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "phpmyadmin", + Short: "Generate access to a read-only phpMyAdmin web interface", + Long: "Generate access to a read-only phpMyAdmin web interface for the environment's database.\n\n" + + "By default the URL is opened in your browser. Use --print to write it to stdout instead.", + } + cmd.Flags().BoolP("print", "p", false, "Print the phpMyAdmin URL to stdout instead of opening it in a browser.") + cmd.Flags().BoolP("silent", "s", false, "Do not print any output to the console.") + return buildAppEnvCmd(cmd, runDBPhpmyadmin) +} + +// The phpMyAdmin timings follow the documented VIP_*_MS knob shape +// (VIP_BACKUP_DB_INTERVAL_MS, VIP_EXPORT_SQL_INTERVAL_MS) so the 6h ceiling +// and the 30s load-balancer settle are reachable in a test. + +// phpmyadminPollInterval — pollUntil's 1000ms tick (phpmyadmin.ts:217). +func phpmyadminPollInterval() time.Duration { + if v := os.Getenv("VIP_PHPMYADMIN_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return phpmyadmin.DefaultPollInterval +} + +// phpmyadminPollTimeout — pollUntil's default 6h ceiling (utils.ts:18). +func phpmyadminPollTimeout() time.Duration { + if v := os.Getenv("VIP_PHPMYADMIN_TIMEOUT_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return phpmyadmin.DefaultPollTimeout +} + +// phpmyadminPostEnableWait — the 30s LB settle after a cold enable +// (phpmyadmin.ts:219-220). An explicit 0 disables the wait entirely, which +// is expressed to internal/phpmyadmin as a negative duration (its zero value +// means "use the default"). +func phpmyadminPostEnableWait() time.Duration { + if v := os.Getenv("VIP_PHPMYADMIN_POST_ENABLE_WAIT_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + if n <= 0 { + return -1 + } + return time.Duration(n) * time.Millisecond + } + } + return phpmyadmin.DefaultPostEnableWait +} + +func runDBPhpmyadmin(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + printURL, _ := cmd.Flags().GetBool("print") + silent, _ := cmd.Flags().GetBool("silent") + cfg := GetConfig() + + trackEvent("phpmyadmin_command_execute", map[string]any{ + "app": ae.App.ID, + "env": ae.Env.ID, + }) + + // Node prints a yellow warning that PMA sessions are read-only before + // kicking the progress tracker. We match that here, but on stderr to + // keep stdout clean for --print consumers. + if !silent { + fmt.Fprintln(cmd.ErrOrStderr(), color.YellowString( + "Note: PHPMyAdmin sessions are read-only. If you run a query that writes to DB, it will fail.")) + } + + res, err := phpmyadmin.Run(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, phpmyadmin.RunOpts{ + Silent: silent, + Stderr: cmd.ErrOrStderr(), + PollInterval: phpmyadminPollInterval(), + PollTimeout: phpmyadminPollTimeout(), + PostEnableWait: phpmyadminPostEnableWait(), + }) + if err != nil { + trackEvent("phpmyadmin_command_error", map[string]any{"error": err.Error()}) + return err + } + trackEvent("phpmyadmin_command_success", nil) + + if printURL { + fmt.Fprintln(cmd.OutOrStdout(), res.URL) + return nil + } + if !silent { + fmt.Fprintln(cmd.ErrOrStderr(), "phpMyAdmin is opened in your default browser.") + } + return openURLFn(res.URL) +} diff --git a/cmd/vip-next/commands/db_phpmyadmin_test.go b/cmd/vip-next/commands/db_phpmyadmin_test.go new file mode 100644 index 000000000..0f474c995 --- /dev/null +++ b/cmd/vip-next/commands/db_phpmyadmin_test.go @@ -0,0 +1,277 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/phpmyadmin" +) + +// phpmyadminStubServer dispatches on operationName so a single test stub can +// answer all three ops in the enable + poll + generate flow. +type phpmyadminStubServer struct { + enableBody []byte + statusBody []byte + generateBody []byte + enableHits int32 + statusHits int32 + generateHits int32 +} + +func (s *phpmyadminStubServer) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + ops := string(body) + switch { + case strings.Contains(ops, `"operationName":"EnablePhpMyAdmin"`): + atomic.AddInt32(&s.enableHits, 1) + _, _ = w.Write(s.enableBody) + case strings.Contains(ops, `"operationName":"PhpMyAdminStatus"`): + atomic.AddInt32(&s.statusHits, 1) + _, _ = w.Write(s.statusBody) + case strings.Contains(ops, `"operationName":"GeneratePhpMyAdminAccess"`): + atomic.AddInt32(&s.generateHits, 1) + _, _ = w.Write(s.generateBody) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) +} + +// setupPhpmyadminConfig swaps the openURLFn seam in addition to wiring the +// genqlient client, so the default (non --print) branch doesn't actually +// launch a browser. Returns a cleanup func that restores both. +func setupPhpmyadminConfig(srv *httptest.Server, opened *string) func() { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + prevCfg := GetConfig() + SetConfig(Config{GQLClient: c}) + prevOpen := openURLFn + openURLFn = func(url string) error { + if opened != nil { + *opened = url + } + return nil + } + return func() { + SetConfig(prevCfg) + openURLFn = prevOpen + } +} + +func successStub() *phpmyadminStubServer { + return &phpmyadminStubServer{ + enableBody: []byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`), + statusBody: []byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`), + generateBody: []byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/abc"}}}`), + } +} + +func TestDBPhpmyadminPrintWritesURLToStdout(t *testing.T) { + stub := successStub() + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + var opened string + cleanup := setupPhpmyadminConfig(srv, &opened) + defer cleanup() + + cmd := DBPhpmyadminCmd() + _ = cmd.Flags().Set("print", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runDBPhpmyadmin(cmd, nil); err != nil { + t.Fatalf("runDBPhpmyadmin: %v", err) + } + if strings.TrimSpace(stdout.String()) != "https://pma.example/abc" { + t.Errorf("stdout = %q, want URL", stdout.String()) + } + if opened != "" { + t.Errorf("--print must not open browser; got %q", opened) + } +} + +func TestDBPhpmyadminPrintSilentSuppressesStderr(t *testing.T) { + stub := successStub() + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + cleanup := setupPhpmyadminConfig(srv, nil) + defer cleanup() + + cmd := DBPhpmyadminCmd() + _ = cmd.Flags().Set("print", "true") + _ = cmd.Flags().Set("silent", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runDBPhpmyadmin(cmd, nil); err != nil { + t.Fatalf("runDBPhpmyadmin: %v", err) + } + if strings.TrimSpace(stdout.String()) != "https://pma.example/abc" { + t.Errorf("stdout = %q, want URL even when silent", stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("--silent must suppress stderr; got %q", stderr.String()) + } +} + +func TestDBPhpmyadminDefaultOpensBrowser(t *testing.T) { + stub := successStub() + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + var opened string + cleanup := setupPhpmyadminConfig(srv, &opened) + defer cleanup() + + cmd := DBPhpmyadminCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runDBPhpmyadmin(cmd, nil); err != nil { + t.Fatalf("runDBPhpmyadmin: %v", err) + } + if opened != "https://pma.example/abc" { + t.Errorf("openURLFn called with %q, want URL", opened) + } + if stdout.Len() != 0 { + t.Errorf("default mode must not write to stdout; got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "phpMyAdmin is opened in your default browser") { + t.Errorf("default mode missing 'opened in your default browser' line; stderr=%q", stderr.String()) + } +} + +// TestDBPhpmyadminAlreadyRunningSkipsEnableMutation is the command-level +// regression test for the extra enable mutation: `vip db phpmyadmin` used to +// fire EnablePhpMyAdmin on EVERY invocation, even against an environment +// whose phpMyAdmin was already running (phpmyadmin.ts:214-215 checks first). +func TestDBPhpmyadminAlreadyRunningSkipsEnableMutation(t *testing.T) { + stub := successStub() + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + cleanup := setupPhpmyadminConfig(srv, nil) + defer cleanup() + + cmd := DBPhpmyadminCmd() + _ = cmd.Flags().Set("print", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + if err := runDBPhpmyadmin(cmd, nil); err != nil { + t.Fatalf("runDBPhpmyadmin: %v", err) + } + if stub.enableHits != 0 { + t.Errorf("EnablePhpMyAdmin fired %d times against a running environment, want 0", + stub.enableHits) + } +} + +// TestPhpmyadminPollTimeoutKnob: the ceiling defaults to Node's 6h (NOT the +// 60s Go used to hard-code) and is overridable through the same +// VIP_*_MS knob shape as VIP_BACKUP_DB_INTERVAL_MS / VIP_EXPORT_SQL_INTERVAL_MS, +// so the ceiling is exercisable in a test without a six-hour wait. +func TestPhpmyadminPollTimeoutKnob(t *testing.T) { + if got := phpmyadminPollTimeout(); got != phpmyadmin.DefaultPollTimeout { + t.Errorf("phpmyadminPollTimeout() = %v, want %v", got, phpmyadmin.DefaultPollTimeout) + } + t.Setenv("VIP_PHPMYADMIN_TIMEOUT_MS", "25") + if got := phpmyadminPollTimeout(); got != 25*time.Millisecond { + t.Errorf("with knob set: %v, want 25ms", got) + } + + if got := phpmyadminPollInterval(); got != phpmyadmin.DefaultPollInterval { + t.Errorf("phpmyadminPollInterval() = %v, want %v", got, phpmyadmin.DefaultPollInterval) + } + t.Setenv("VIP_PHPMYADMIN_INTERVAL_MS", "3") + if got := phpmyadminPollInterval(); got != 3*time.Millisecond { + t.Errorf("with knob set: %v, want 3ms", got) + } + + if got := phpmyadminPostEnableWait(); got != phpmyadmin.DefaultPostEnableWait { + t.Errorf("phpmyadminPostEnableWait() = %v, want %v", got, phpmyadmin.DefaultPostEnableWait) + } + t.Setenv("VIP_PHPMYADMIN_POST_ENABLE_WAIT_MS", "0") + if got := phpmyadminPostEnableWait(); got >= 0 { + t.Errorf("with knob set to 0: %v, want a negative value (skip the wait)", got) + } +} + +// TestDBPhpmyadminStopsAtPollCeiling drives the whole command against an +// environment that never reports "running": with the ceiling knob turned +// down the command must fail instead of polling forever. +func TestDBPhpmyadminStopsAtPollCeiling(t *testing.T) { + t.Setenv("VIP_PHPMYADMIN_INTERVAL_MS", "1") + t.Setenv("VIP_PHPMYADMIN_TIMEOUT_MS", "30") + t.Setenv("VIP_PHPMYADMIN_POST_ENABLE_WAIT_MS", "0") + stub := &phpmyadminStubServer{ + enableBody: []byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`), + statusBody: []byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"pending"}}]}}}`), + generateBody: []byte(`{"data":null}`), + } + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + cleanup := setupPhpmyadminConfig(srv, nil) + defer cleanup() + + cmd := DBPhpmyadminCmd() + _ = cmd.Flags().Set("print", "true") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(ctxWithAppEnv(1, 2)) + + done := make(chan error, 1) + go func() { done <- runDBPhpmyadmin(cmd, nil) }() + select { + case err := <-done: + const want = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." + if err == nil || err.Error() != want { + t.Errorf("err = %v, want %q", err, want) + } + case <-time.After(5 * time.Second): + t.Fatal("runDBPhpmyadmin never returned") + } +} + +func TestDBPhpmyadminEnableUnauthorizedReturnsPermissionMessage(t *testing.T) { + stub := &phpmyadminStubServer{ + enableBody: []byte(`{"errors":[{"message":"Unauthorized"}],"data":null}`), + statusBody: []byte(`{"data":null}`), + generateBody: []byte(`{"data":null}`), + } + srv := httptest.NewServer(stub.handler(t)) + defer srv.Close() + cleanup := setupPhpmyadminConfig(srv, nil) + defer cleanup() + + cmd := DBPhpmyadminCmd() + _ = cmd.Flags().Set("print", "true") + cmd.SetContext(ctxWithAppEnv(1, 2)) + + err := runDBPhpmyadmin(cmd, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + const want = "You do not have sufficient permission to access phpMyAdmin for this environment." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} diff --git a/cmd/vip-next/commands/defensive_mode.go b/cmd/vip-next/commands/defensive_mode.go new file mode 100644 index 000000000..f3ba0beee --- /dev/null +++ b/cmd/vip-next/commands/defensive_mode.go @@ -0,0 +1,106 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// NewDefensiveModeCmd returns the `defensive-mode` parent with all three +// subcommands attached. M4: root's --app/--env flags (alias-aware) replace +// the M3 numeric ID placeholders; WithAppContext + WithEnvContext middleware +// resolve them into the cmd.Context() AppEnv via GraphQL. +func NewDefensiveModeCmd() *cobra.Command { + parent := &cobra.Command{ + Use: "defensive-mode", + Short: "Manage WAF defensive mode for an environment.", + Long: "Enable, disable, or configure WAF defensive mode for an environment. Mutations on production require step-up authentication.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + // --non-interactive lives at root (cmd/vip-next/root.go) so the + // rechallenge middleware in main.go can resolve interactivity from a + // single source. --skip-confirmation is command-tree-local to defensive- + // mode because no other command has a production-confirm gate. + parent.PersistentFlags().Bool("skip-confirmation", false, "Skip the production confirmation prompt.") + // --rechallenge-wait is registered here, on the only command tree that + // deliberately trips step-up, matching where Node registers it + // (src/bin/vip-defensive-mode-{enable,disable,configure}.js). The flag is + // read off the raw command line by rechallenge.ShouldWaitForRechallenge + // rather than through cobra: the step-up middleware is constructed once at + // startup and has no access to a command's parsed options. Registering it + // is still required — otherwise cobra rejects it as an unknown flag before + // the middleware ever runs. VIP_RECHALLENGE_WAIT=1 does the same thing + // everywhere, and is what the fail-fast error points people at. + parent.PersistentFlags().Bool("rechallenge-wait", false, + "When step-up verification is required non-interactively, print the URL and wait for verification on another device instead of failing fast.") + + parent.AddCommand(newDefensiveModeEnableCmd()) + parent.AddCommand(newDefensiveModeDisableCmd()) + parent.AddCommand(newDefensiveModeConfigureCmd()) + return parent +} + +// addAppEnvFlags registers command-LOCAL --app/--env flags carrying the -a/-e +// shorthands Node derives for every command with appContext/envContext +// (src/lib/cli/command.js:1075-1084 feeding createOptionDefinition). +// +// vip-next keeps --app/--env as root persistent flags so the @app.env alias +// has one place to land. They cannot carry the shorthands there: dev-env +// leaves legitimately use -a for --all/--app-code and -e for +// --elasticsearch/--editor/--extended, and pflag panics when a persistent +// shorthand collides with a local one. A same-named local flag is the +// per-command registration Node actually has — cobra's mergePersistentFlags +// skips a parent flag whose name is already present locally, and every reader +// (root's PersistentPreRunE, appctx's app/env resolvers) goes through +// cmd.Flag/cmd.Flags, so the local flag is the one that is read. +func addAppEnvFlags(c *cobra.Command) { + if c.Flags().Lookup("app") == nil { + c.Flags().StringP("app", "a", "", + "Target an application. Accepts a string value for the application name or an integer for the application ID.") + } + if c.Flags().Lookup("env") == nil { + c.Flags().StringP("env", "e", "", + "Target an environment. Accepts a string value for the environment type.") + } +} + +// buildAppEnvCmd wires the standard WithAppContext + WithEnvContext middleware +// chain onto a leaf command. Shared by enable / disable / configure so the +// handlers can read a fully resolved AppEnv from cmd.Context(). +func buildAppEnvCmd(c *cobra.Command, handler appctx.RunFunc) *cobra.Command { + addAppEnvFlags(c) + cfg := GetConfig() + return appctx.Build(c, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(handler) +} + +// buildAppEnvRenderableCmd is buildAppEnvCmd's parallel for handlers that +// return (any, error) — i.e. handlers behind WithFormat. The WithFormat +// middleware is applied here against the handler before passing to +// WithRenderableRun. +func buildAppEnvRenderableCmd(c *cobra.Command, defaultFormat string, allowed []string, handler appctx.RenderableRunFunc) *cobra.Command { + addAppEnvFlags(c) + cfg := GetConfig() + return appctx.Build(c, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRenderableRun( + appctx.WithFormat(c, defaultFormat, allowed...)(handler), + ) +} + +// trackEvent is a nil-safe wrapper over cfg.Tracker.TrackEvent. Tracker is +// non-nil in production (telemetry.NewDefault always returns a value), but +// some tests construct a Config without a Tracker and we don't want them to +// panic. +func trackEvent(name string, props map[string]any) { + cfg := GetConfig() + if cfg.Tracker == nil { + return + } + cfg.Tracker.TrackEvent(name, props) +} diff --git a/cmd/vip-next/commands/defensive_mode_configure.go b/cmd/vip-next/commands/defensive_mode_configure.go new file mode 100644 index 000000000..bbea3d75c --- /dev/null +++ b/cmd/vip-next/commands/defensive_mode_configure.go @@ -0,0 +1,150 @@ +package commands + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/defensivemode" +) + +func newDefensiveModeConfigureCmd() *cobra.Command { + c := &cobra.Command{ + Use: "configure", + Short: "Update the defensive mode configuration (step-up auth required).", + Long: "Update the defensive mode configuration for the target environment. Use --enabled and --challenge-type as required flags.", + } + c.Flags().String("enabled", "", "Whether defensive mode should be enabled (true|false). Required.") + c.Flags().String("challenge-type", "", "Challenge type integer. Required.") + c.Flags().String("connection-threshold-absolute", "", "Absolute connection threshold.") + c.Flags().String("connection-threshold-percentage", "", "Connection threshold percentage.") + return buildAppEnvCmd(c, runDefensiveModeConfigure) +} + +func runDefensiveModeConfigure(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + skipConfirm, _ := cmd.Flags().GetBool("skip-confirmation") + enabledRaw, _ := cmd.Flags().GetString("enabled") + challengeTypeRaw, _ := cmd.Flags().GetString("challenge-type") + absRaw, _ := cmd.Flags().GetString("connection-threshold-absolute") + pctRaw, _ := cmd.Flags().GetString("connection-threshold-percentage") + + // Validate flag formats first. + enabled, err := parseBoolean(enabledRaw) + if enabledRaw != "" && err != nil { + return fmt.Errorf("invalid value for --enabled: %s. Expected true or false", enabledRaw) + } + challengeType, err := parsePositiveInt(challengeTypeRaw) + if challengeTypeRaw != "" && err != nil { + return fmt.Errorf("invalid value for --challenge-type: %s. Expected a non-negative integer", challengeTypeRaw) + } + abs, err := parsePositiveIntPtr(absRaw) + if absRaw != "" && err != nil { + return fmt.Errorf("invalid value for --connection-threshold-absolute: %s", absRaw) + } + pct, err := parsePositiveIntPtr(pctRaw) + if pctRaw != "" && err != nil { + return fmt.Errorf("invalid value for --connection-threshold-percentage: %s", pctRaw) + } + + // Missing-required prompt path (interactive only). The Confirm/Input + // helpers themselves return ErrNonInteractive in non-interactive mode, + // so we don't have to gate on IsInteractive here. + if enabledRaw == "" { + picked, perr := appctx.Confirm(cmd, "Enable defensive mode?", true) + if perr != nil { + return fmt.Errorf("--enabled is required: %w", perr) + } + enabled = picked + } + if challengeTypeRaw == "" { + raw, perr := appctx.Input(cmd, "Challenge type (integer):", "") + if perr != nil { + return fmt.Errorf("--challenge-type is required: %w", perr) + } + n, ierr := strconv.Atoi(strings.TrimSpace(raw)) + if ierr != nil || n < 0 { + return fmt.Errorf("invalid challenge type %q", raw) + } + challengeType = n + } + + // Production guard. + if ae.Env.Type == "production" && !skipConfirm { + if !appctx.IsInteractive(cmd) { + trackEvent("defensive_mode_configure_command_cancelled", nil) + return fmt.Errorf("refusing to configure defensive mode on production without --skip-confirmation in non-interactive mode") + } + ok, perr := appctx.Confirm(cmd, + fmt.Sprintf("Configure defensive mode on production for %s?", ae.App.Name), false) + if perr != nil || !ok { + trackEvent("defensive_mode_configure_command_cancelled", nil) + if perr != nil { + return perr + } + fmt.Fprintln(cmd.ErrOrStderr(), "Command cancelled") + return nil + } + } + + input := defensivemode.UpdateConfigInput{ + AppID: ae.App.ID, + EnvID: ae.Env.ID, + Enabled: enabled, + ChallengeType: challengeType, + ConnectionThresholdAbsolute: abs, + ConnectionThresholdPercentage: pct, + } + result, err := defensivemode.UpdateDefensiveModeConfig(cmd.Context(), cfg.GQLClient, input) + if err != nil { + return err + } + if !result.Success { + trackEvent("defensive_mode_configure_command_error", map[string]any{"error": result.Message}) + return fmt.Errorf("failed to update defensive mode config: %s", result.Message) + } + trackEvent("defensive_mode_configure_command_success", nil) + fmt.Fprintf(cmd.OutOrStdout(), "✓ Defensive mode configuration updated for %s.%s — %s\n", ae.App.Name, ae.Env.Type, result.Message) + return nil +} + +func parseBoolean(raw string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "true", "yes", "1", "on", "enable", "enabled": + return true, nil + case "false", "no", "0", "off", "disable", "disabled": + return false, nil + } + return false, fmt.Errorf("unparseable boolean: %q", raw) +} + +func parsePositiveInt(raw string) (int, error) { + if raw == "" { + return 0, fmt.Errorf("empty") + } + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 0 { + return 0, fmt.Errorf("not a non-negative integer: %q", raw) + } + return n, nil +} + +func parsePositiveIntPtr(raw string) (*int, error) { + if raw == "" { + return nil, nil + } + n, err := parsePositiveInt(raw) + if err != nil { + return nil, err + } + return &n, nil +} diff --git a/cmd/vip-next/commands/defensive_mode_disable.go b/cmd/vip-next/commands/defensive_mode_disable.go new file mode 100644 index 000000000..a46147893 --- /dev/null +++ b/cmd/vip-next/commands/defensive_mode_disable.go @@ -0,0 +1,62 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/defensivemode" +) + +func newDefensiveModeDisableCmd() *cobra.Command { + c := &cobra.Command{ + Use: "disable", + Short: "Disable defensive mode (step-up auth required).", + Long: "Disable WAF defensive mode for the target environment. Step-up auth is required on production.", + } + return buildAppEnvCmd(c, runDefensiveModeDisable) +} + +func runDefensiveModeDisable(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + skipConfirm, _ := cmd.Flags().GetBool("skip-confirmation") + + if ae.Env.Type == "production" && !skipConfirm { + if !appctx.IsInteractive(cmd) { + trackEvent("defensive_mode_disable_command_cancelled", nil) + return fmt.Errorf("refusing to disable defensive mode on production without --skip-confirmation in non-interactive mode") + } + ok, err := appctx.Confirm(cmd, + fmt.Sprintf("Disable defensive mode on production for %s?", ae.App.Name), false) + if err != nil || !ok { + trackEvent("defensive_mode_disable_command_cancelled", nil) + if err != nil { + return err + } + fmt.Fprintln(cmd.ErrOrStderr(), "Command cancelled") + return nil + } + } + + result, err := defensivemode.UpdateDefensiveModeStatus(cmd.Context(), cfg.GQLClient, defensivemode.UpdateStatusInput{ + AppID: ae.App.ID, + EnvID: ae.Env.ID, + Enabled: false, + }) + if err != nil { + return err + } + if !result.Success { + trackEvent("defensive_mode_disable_command_error", map[string]any{"error": result.Message}) + return fmt.Errorf("failed to disable defensive mode: %s", result.Message) + } + trackEvent("defensive_mode_disable_command_success", nil) + fmt.Fprintf(cmd.OutOrStdout(), "✓ Defensive mode disabled for %s.%s — %s\n", ae.App.Name, ae.Env.Type, result.Message) + return nil +} diff --git a/cmd/vip-next/commands/defensive_mode_enable.go b/cmd/vip-next/commands/defensive_mode_enable.go new file mode 100644 index 000000000..fa0f112cb --- /dev/null +++ b/cmd/vip-next/commands/defensive_mode_enable.go @@ -0,0 +1,62 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/defensivemode" +) + +func newDefensiveModeEnableCmd() *cobra.Command { + c := &cobra.Command{ + Use: "enable", + Short: "Enable defensive mode (step-up auth required).", + Long: "Enable WAF defensive mode for the target environment. Step-up auth is required on production.", + } + return buildAppEnvCmd(c, runDefensiveModeEnable) +} + +func runDefensiveModeEnable(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + skipConfirm, _ := cmd.Flags().GetBool("skip-confirmation") + + if ae.Env.Type == "production" && !skipConfirm { + if !appctx.IsInteractive(cmd) { + trackEvent("defensive_mode_enable_command_cancelled", nil) + return fmt.Errorf("refusing to enable defensive mode on production without --skip-confirmation in non-interactive mode") + } + ok, err := appctx.Confirm(cmd, + fmt.Sprintf("Enable defensive mode on production for %s?", ae.App.Name), false) + if err != nil || !ok { + trackEvent("defensive_mode_enable_command_cancelled", nil) + if err != nil { + return err + } + fmt.Fprintln(cmd.ErrOrStderr(), "Command cancelled") + return nil + } + } + + result, err := defensivemode.UpdateDefensiveModeStatus(cmd.Context(), cfg.GQLClient, defensivemode.UpdateStatusInput{ + AppID: ae.App.ID, + EnvID: ae.Env.ID, + Enabled: true, + }) + if err != nil { + return err + } + if !result.Success { + trackEvent("defensive_mode_enable_command_error", map[string]any{"error": result.Message}) + return fmt.Errorf("failed to enable defensive mode: %s", result.Message) + } + trackEvent("defensive_mode_enable_command_success", nil) + fmt.Fprintf(cmd.OutOrStdout(), "✓ Defensive mode enabled for %s.%s — %s\n", ae.App.Name, ae.Env.Type, result.Message) + return nil +} diff --git a/cmd/vip-next/commands/defensive_mode_test.go b/cmd/vip-next/commands/defensive_mode_test.go new file mode 100644 index 000000000..53268e1fa --- /dev/null +++ b/cmd/vip-next/commands/defensive_mode_test.go @@ -0,0 +1,389 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/telemetry" +) + +// graphqlMock returns a test server whose /graphql handler dispatches POST +// bodies on operationName: ResolveAppByName / ResolveAppByID -> fixed +// app+env shape; UpdateDefensiveMode{Status,Config} -> success payload. +// The second return value is a helper that reads the body of the most +// recent mutation request (under a mutex so the race detector is happy). +func graphqlMock(t *testing.T) (*httptest.Server, func() string) { + t.Helper() + var mu sync.Mutex + var lastBody string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(s, `"operationName":"ResolveAppByName"`) || + strings.Contains(s, `"operationName":"ResolveAppByID"`): + // Fixed fixture: app id=42 named "myapp" with two envs. + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"},{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}]},"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"},{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}]}]}}}`)) + case strings.Contains(s, `"operationName":"UpdateDefensiveModeStatus"`): + mu.Lock() + lastBody = s + mu.Unlock() + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true,"message":"ok"}}}`)) + case strings.Contains(s, `"operationName":"UpdateDefensiveModeConfig"`): + mu.Lock() + lastBody = s + mu.Unlock() + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeConfig":{"success":true,"message":"ok"}}}`)) + default: + t.Errorf("unexpected GraphQL request: %s", s) + w.WriteHeader(http.StatusBadRequest) + } + })) + return srv, func() string { + mu.Lock() + defer mu.Unlock() + return lastBody + } +} + +// setupTestConfig wires SetConfig with a genqlient client pointed at srv. +// The tracker is explicitly Disabled so we never touch the real Tracks / +// Pendo endpoints during a unit test — telemetry.NewDefault() would honor +// GO_ENV=test, but we don't want to rely on a global env var being set. +func setupTestConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{ + APIHost: srv.URL, + Token: "t", + GQLClient: c, + Tracker: &telemetry.Tracker{Disabled: true}, + AppCtxConfig: appctx.AppContextConfig{Client: c}, + }) +} + +// runDefensiveModeCmd is the test harness for the parent + subcommand +// chain. The production root command declares --app/--env/--non-interactive +// persistently; here we declare them locally on the parent because we're +// not using root.go. +func runDefensiveModeCmd(t *testing.T, args ...string) error { + t.Helper() + cmd := NewDefensiveModeCmd() + cmd.PersistentFlags().String("app", "", "") + cmd.PersistentFlags().String("env", "", "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestDefensiveModeEnableCalledCorrectly(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + if err := runDefensiveModeCmd(t, + "enable", "--app=myapp", "--env=develop", + "--skip-confirmation", "--non-interactive", + ); err != nil { + t.Fatalf("Execute: %v", err) + } + body := lastBody() + if !strings.Contains(body, "UpdateDefensiveModeStatus") { + t.Errorf("body missing operation: %s", body) + } + if !strings.Contains(body, `"enabled":true`) { + t.Errorf("enabled should be true: %s", body) + } + if !strings.Contains(body, `"id":42`) || !strings.Contains(body, `"environmentId":7`) { + t.Errorf("input ids missing (id=42, environmentId=7); body=%s", body) + } +} + +func TestDefensiveModeDisableCalledCorrectly(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + if err := runDefensiveModeCmd(t, + "disable", "--app=myapp", "--env=develop", + "--skip-confirmation", "--non-interactive", + ); err != nil { + t.Fatalf("Execute: %v", err) + } + body := lastBody() + if !strings.Contains(body, `"enabled":false`) { + t.Errorf("disable must send enabled:false; body=%s", body) + } +} + +func TestDefensiveModeParentCommandHasSubcommands(t *testing.T) { + parent := NewDefensiveModeCmd() + for _, sub := range []string{"enable", "disable", "configure"} { + if findSub(parent, sub) == nil { + t.Errorf("missing subcommand %q", sub) + } + } +} + +func findSub(c *cobra.Command, name string) *cobra.Command { + for _, sub := range c.Commands() { + if sub.Use == name || strings.HasPrefix(sub.Use, name+" ") { + return sub + } + } + return nil +} + +func TestDefensiveModeConfigureMissingRequired(t *testing.T) { + srv, _ := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + err := runDefensiveModeCmd(t, + "configure", "--app=myapp", "--env=develop", "--non-interactive", + ) + if err == nil { + t.Error("missing --enabled/--challenge-type in non-interactive mode must error") + } +} + +func TestDefensiveModeConfigureValid(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + if err := runDefensiveModeCmd(t, + "configure", "--app=myapp", "--env=develop", + "--enabled=true", "--challenge-type=2", + "--connection-threshold-absolute=5000", + "--connection-threshold-percentage=80", + "--skip-confirmation", "--non-interactive", + ); err != nil { + t.Fatalf("Execute: %v", err) + } + body := lastBody() + if !strings.Contains(body, "UpdateDefensiveModeConfig") { + t.Errorf("operation missing: %s", body) + } + if !strings.Contains(body, `"challengeType":2`) { + t.Errorf("challengeType missing: %s", body) + } + if !strings.Contains(body, `"connectionThresholdAbsolute":5000`) { + t.Errorf("absolute threshold missing: %s", body) + } +} + +// ── production guard ────────────────────────────────────────────────────── +// +// All three subcommands gate on `ae.Env.Type == "production"`, and until now +// every test drove --env=develop, so the guard on the fixture's production +// environment (id 1) was never exercised at all. These tests pin both halves: +// the guard refuses without --skip-confirmation, and it does not fire on a +// non-production environment. +// +// The interactive decline branch (a TTY user answering "n") is deliberately +// NOT converted to a non-zero exit: `defensive-mode` has no Node counterpart, +// and Node's convention for a declined destructive confirm is exit 0 — +// `console.log( 'Command cancelled' ); process.exit();` in vip-wp.js:396-397 +// (the closest analogue: a production-only gate on a mutating command), +// vip-config-envvar-set.js:66-67, vip-config-envvar-delete.js:61-62, and +// command.js:987-991 for every requireConfirm command. Non-interactive is the +// scriptable path and it already exits 1, which is what CI needs. + +// runDefensiveModeCmdCapturing is runDefensiveModeCmd plus stdout/stderr capture. +func runDefensiveModeCmdCapturing(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := NewDefensiveModeCmd() + cmd.PersistentFlags().String("app", "", "") + cmd.PersistentFlags().String("env", "", "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func TestDefensiveModeProductionGuardBlocksWithoutSkipConfirmation(t *testing.T) { + cases := []struct { + name string + argv []string + want string + }{ + {"enable", []string{"enable"}, "refusing to enable defensive mode on production"}, + {"disable", []string{"disable"}, "refusing to disable defensive mode on production"}, + { + "configure", + []string{"configure", "--enabled=true", "--challenge-type=2"}, + "refusing to configure defensive mode on production", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + argv := append(append([]string{}, tc.argv...), + "--app=myapp", "--env=production", "--non-interactive") + err := runDefensiveModeCmd(t, argv...) + if err == nil { + t.Fatal("production without --skip-confirmation must fail") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %q, want %q", err, tc.want) + } + // The guard is only worth anything if the mutation never left. + if body := lastBody(); body != "" { + t.Errorf("a blocked production mutation was sent anyway: %s", body) + } + }) + } +} + +func TestDefensiveModeProductionGuardPassesWithSkipConfirmation(t *testing.T) { + cases := []struct { + name string + argv []string + want string + }{ + {"enable", []string{"enable"}, `"enabled":true`}, + {"disable", []string{"disable"}, `"enabled":false`}, + { + "configure", + []string{"configure", "--enabled=true", "--challenge-type=2"}, + `"challengeType":2`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + argv := append(append([]string{}, tc.argv...), + "--app=myapp", "--env=production", "--skip-confirmation", "--non-interactive") + if err := runDefensiveModeCmd(t, argv...); err != nil { + t.Fatalf("Execute: %v", err) + } + body := lastBody() + if !strings.Contains(body, tc.want) { + t.Errorf("body = %s, want %q", body, tc.want) + } + // Production is environment id 1 in the fixture — proves the + // mutation targeted production and not the develop default. + if !strings.Contains(body, `"environmentId":1`) { + t.Errorf("mutation did not target production (environmentId 1): %s", body) + } + }) + } +} + +// The guard must not fire on a non-production environment: `enable` on develop +// with no --skip-confirmation still mutates, in a non-interactive session. +func TestDefensiveModeNonProductionNeedsNoConfirmation(t *testing.T) { + srv, lastBody := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + out, err := runDefensiveModeCmdCapturing(t, + "enable", "--app=myapp", "--env=develop", "--non-interactive") + if err != nil { + t.Fatalf("develop must not require --skip-confirmation: %v", err) + } + if body := lastBody(); !strings.Contains(body, `"environmentId":7`) { + t.Errorf("mutation missing or wrong env: %s", body) + } + if !strings.Contains(out, "Defensive mode enabled") { + t.Errorf("output = %q, want the success line", out) + } +} + +// A non-interactive step-up now fails fast, and the way back to the old +// behavior is --rechallenge-wait (or VIP_RECHALLENGE_WAIT=1). The flag is read +// off the raw command line by the step-up middleware, but cobra still has to +// accept it — an unknown flag is rejected before the middleware ever runs, +// which would make the documented escape hatch unusable on every one of these +// commands. Node registers it on the same three (src/bin/vip-defensive-mode-*). +func TestDefensiveModeAcceptsRechallengeWaitFlag(t *testing.T) { + for _, sub := range []string{"enable", "disable", "configure"} { + t.Run(sub, func(t *testing.T) { + srv, _ := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + + argv := []string{sub, "--app=myapp", "--env=develop", + "--non-interactive", "--skip-confirmation", "--rechallenge-wait"} + if sub == "configure" { + argv = append(argv, "--enabled=true", "--challenge-type=1") + } + if err := runDefensiveModeCmd(t, argv...); err != nil { + t.Fatalf("%s must accept --rechallenge-wait: %v", sub, err) + } + }) + } +} + +// The success line identifies the target as ., matching Node's +// reportMutationResult (`${appName}.${envType}`, cli-helpers.ts:92) and every +// other vip-next command, all of which render the environment from Env.Type. +// These three printed Env.Type's sibling field, Env.Name — invisible in the +// shared fixture, where the two are equal, and wrong for any environment with +// a custom name. The fixture here deliberately makes them differ. +func TestDefensiveModeSuccessLineNamesTheEnvironmentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(s, `"operationName":"ResolveAppBy`): + // name "Nightly Build" vs type "develop". + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"myapp","environments":[{"id":7,"name":"Nightly Build","type":"develop","defaultDomain":"d.example"}]},"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"Nightly Build","type":"develop","defaultDomain":"d.example"}]}]}}}`)) + default: + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true,"message":"ok"}}}`)) + } + })) + defer srv.Close() + setupTestConfig(srv) + + // A custom-named env is addressed by its "." identifier. + out, err := runDefensiveModeCmdCapturing(t, + "enable", "--app=myapp", "--env=develop.Nightly Build", "--non-interactive") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(out, "myapp.develop") { + t.Errorf("success line must identify myapp.develop; got %q", out) + } + if strings.Contains(out, "Nightly Build") { + t.Errorf("success line used the environment's display name, not its type: %q", out) + } +} + +func TestDefensiveModeConfigureBadBoolean(t *testing.T) { + srv, _ := graphqlMock(t) + defer srv.Close() + setupTestConfig(srv) + err := runDefensiveModeCmd(t, + "configure", "--app=myapp", "--env=develop", + "--enabled=maybe", "--challenge-type=1", + "--non-interactive", "--skip-confirmation", + ) + if err == nil { + t.Error("--enabled=maybe must fail") + } +} diff --git a/cmd/vip-next/commands/devenv.go b/cmd/vip-next/commands/devenv.go new file mode 100644 index 000000000..e97bbd5ae --- /dev/null +++ b/cmd/vip-next/commands/devenv.go @@ -0,0 +1,48 @@ +package commands + +import ( + "github.com/spf13/cobra" +) + +// addSlugFlag registers the standard -s/--slug flag shared by env-targeting leaves. +func addSlugFlag(c *cobra.Command) { + c.Flags().StringP("slug", "s", "", "A unique name for a local environment.") +} + +// DevEnvCmd returns the real `vip dev-env` command tree (23 leaves). dev-env is +// auth-bypassed EXCEPT `sync` (which calls the VIP platform — see +// internal/auth/bypass.go). +func DevEnvCmd() *cobra.Command { + root := &cobra.Command{Use: "dev-env", Short: "Manage a local VIP development environment"} + + root.AddCommand( + devEnvCreateCmd(), + devEnvStartCmd(), + devEnvStopCmd(), + devEnvDestroyCmd(), + devEnvInfoCmd(), + devEnvListCmd(), + devEnvPurgeCmd(), + devEnvUpdateCmd(), + devEnvExecCmd(), + devEnvShellCmd(), + devEnvLogsCmd(), + devEnvSyncCmd(), + devEnvEnvvarCmd(), + devEnvImportCmd(), + ) + return root +} + +func devEnvUpdateCmd() *cobra.Command { return newDevEnvUpdateCmd() } + +func devEnvExecCmd() *cobra.Command { return newDevEnvExecCmd() } +func devEnvShellCmd() *cobra.Command { return newDevEnvShellCmd() } + +func devEnvLogsCmd() *cobra.Command { return newDevEnvLogsCmd() } + +func devEnvSyncCmd() *cobra.Command { return newDevEnvSyncCmd() } + +func devEnvEnvvarCmd() *cobra.Command { return newDevEnvEnvvarCmd() } + +func devEnvImportCmd() *cobra.Command { return newDevEnvImportCmd() } diff --git a/cmd/vip-next/commands/devenv_create_appinfo.go b/cmd/vip-next/commands/devenv_create_appinfo.go new file mode 100644 index 000000000..d0d73665b --- /dev/null +++ b/cmd/vip-next/commands/devenv_create_appinfo.go @@ -0,0 +1,158 @@ +package commands + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/gql" +) + +// createDefaults are the wizard pre-selected values derived from an app/env when +// `@app.env dev-env create` is used. Mirrors Node's getOptionsFromAppInfo +// (dev-environment-cli.ts:257) — the subset that seeds the create wizard. +type createDefaults struct { + Title string + Multisite bool + PHP string + WordPress string + MediaRedirectDomain string +} + +// buildCreateDefaults maps resolved app/env fields to wizard defaults. Title +// falls back env name → app name (Node: env.name || app.name || ”). +func buildCreateDefaults(appName, envName string, isMultisite bool, primaryDomain, php, wordpress string) createDefaults { + title := envName + if title == "" { + title = appName + } + return createDefaults{ + Title: title, + Multisite: isMultisite, + PHP: php, + WordPress: wordpress, + MediaRedirectDomain: primaryDomain, + } +} + +// fetchAppCreateDefaults best-effort resolves the @app.env alias (propagated +// into --app/--env by the root) and fetches the app info that pre-populates the +// create wizard. Returns nil when no app was given or anything fails — matching +// Node, which wraps getApplicationInformation in try/catch and continues with +// generic defaults after a warning (vip-dev-env-create.js). +func fetchAppCreateDefaults(cmd *cobra.Command) *createDefaults { + appKey := strings.TrimSpace(lookupRootFlag(cmd, "app")) + if appKey == "" { + return nil // local create; no app context + } + cfg := GetConfig() + if cfg.GQLClient == nil { + return nil // auth-bypassed without a client; nothing to fetch + } + envKey := strings.TrimSpace(lookupRootFlag(cmd, "env")) + + warn := func(err error) *createDefaults { + fmt.Fprintln(cmd.ErrOrStderr(), + color.YellowString("Warning:"), + fmt.Sprintf("failed to fetch application %q information: %v", appKey, err)) + return nil + } + + appID, err := resolveAppIDForCreate(cmd.Context(), cfg.GQLClient, appKey) + if err != nil { + return warn(err) + } + resp, err := gql.DevEnvAppInfo(cmd.Context(), cfg.GQLClient, appID) + if err != nil { + return warn(err) + } + if resp == nil || resp.App == nil { + return warn(fmt.Errorf("no app matching %q found", appKey)) + } + env := pickCreateEnv(resp.App.Environments, envKey) + if env == nil { + return warn(fmt.Errorf("no matching environment for %q", appKey)) + } + + d := buildCreateDefaults( + strVal(resp.App.Name), strVal(env.Name), boolVal(env.IsMultisite), + primaryDomainName(env), softwareVersion(env, "php"), softwareVersion(env, "wordpress"), + ) + return &d +} + +// resolveAppIDForCreate turns the --app key into a numeric app ID: a numeric key +// is used directly; a name is resolved via ResolveAppByName (first match). +func resolveAppIDForCreate(ctx context.Context, client graphql.Client, appKey string) (int64, error) { + if id, err := strconv.ParseInt(appKey, 10, 64); err == nil { + return id, nil + } + resp, err := gql.ResolveAppByName(ctx, client, appKey) + if err != nil { + return 0, err + } + if resp == nil || resp.Apps == nil || len(resp.Apps.Edges) == 0 || resp.Apps.Edges[0] == nil || resp.Apps.Edges[0].Id == nil { + return 0, fmt.Errorf("no app matching name %q found", appKey) + } + return *resp.Apps.Edges[0].Id, nil +} + +// pickCreateEnv selects the env matching envType; if envType is empty and there +// is exactly one env, that one is used (Node's single-env shortcut). Returns nil +// when no unambiguous match exists. +func pickCreateEnv(envs []*gql.DevEnvAppInfoAppEnvironmentsAppEnvironment, envType string) *gql.DevEnvAppInfoAppEnvironmentsAppEnvironment { + if envType != "" { + for _, e := range envs { + if e != nil && strVal(e.Type) == envType { + return e + } + } + return nil + } + if len(envs) == 1 { + return envs[0] + } + return nil +} + +func primaryDomainName(env *gql.DevEnvAppInfoAppEnvironmentsAppEnvironment) string { + if env.PrimaryDomain == nil { + return "" + } + return env.PrimaryDomain.Name +} + +// softwareVersion returns the env's current php or wordpress version, or "". +func softwareVersion(env *gql.DevEnvAppInfoAppEnvironmentsAppEnvironment, component string) string { + ss := env.SoftwareSettings + if ss == nil { + return "" + } + switch component { + case "php": + if ss.Php != nil && ss.Php.Current != nil { + return ss.Php.Current.Version + } + case "wordpress": + if ss.Wordpress != nil && ss.Wordpress.Current != nil { + return ss.Wordpress.Current.Version + } + } + return "" +} + +func strVal(p *string) string { + if p == nil { + return "" + } + return *p +} + +func boolVal(p *bool) bool { + return p != nil && *p +} diff --git a/cmd/vip-next/commands/devenv_create_appinfo_test.go b/cmd/vip-next/commands/devenv_create_appinfo_test.go new file mode 100644 index 000000000..b28838533 --- /dev/null +++ b/cmd/vip-next/commands/devenv_create_appinfo_test.go @@ -0,0 +1,29 @@ +package commands + +import "testing" + +func TestBuildCreateDefaultsPrefersEnvName(t *testing.T) { + d := buildCreateDefaults("my-app", "cantina-trunk-staging", false, "example.com", "8.2", "6.4") + if d.Title != "cantina-trunk-staging" { + t.Fatalf("title = %q, want env name", d.Title) + } + if d.Multisite { + t.Fatalf("multisite should be false") + } + if d.PHP != "8.2" || d.WordPress != "6.4" { + t.Fatalf("php/wordpress not mapped: %+v", d) + } + if d.MediaRedirectDomain != "example.com" { + t.Fatalf("mediaRedirectDomain not mapped: %q", d.MediaRedirectDomain) + } +} + +func TestBuildCreateDefaultsFallsBackToAppName(t *testing.T) { + d := buildCreateDefaults("my-app", "", true, "", "", "") + if d.Title != "my-app" { + t.Fatalf("title = %q, want app name fallback", d.Title) + } + if !d.Multisite { + t.Fatalf("multisite should be true") + } +} diff --git a/cmd/vip-next/commands/devenv_create_test.go b/cmd/vip-next/commands/devenv_create_test.go new file mode 100644 index 000000000..7226f9aa6 --- /dev/null +++ b/cmd/vip-next/commands/devenv_create_test.go @@ -0,0 +1,269 @@ +package commands + +import "testing" + +func TestResolveCreateConfigNonInteractiveDefaults(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x"}); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Slug != "x" { + t.Fatalf("Slug = %q", cfg.Slug) + } + if cfg.Title != "VIP Dev" { + t.Fatalf("Title default = %q, want VIP Dev", cfg.Title) + } + if cfg.MultisiteMode != "" { + t.Fatalf("MultisiteMode default = %q, want single site", cfg.MultisiteMode) + } + if cfg.PHP != "" { + t.Fatalf("PHP default = %q, want empty (NewView resolves to recommended)", cfg.PHP) + } + if cfg.PHPMyAdmin || cfg.Xdebug || cfg.Mailpit || cfg.Photon || cfg.Elasticsearch { + t.Fatalf("service toggles should default off: %+v", cfg) + } + if cfg.Start { + t.Fatal("Start must default FALSE (register 2.22)") + } +} + +// Register 2.22. Node's `dev-env create` only writes files — it ends by +// printing "To start the environment run: vip dev-env start" +// (vip-dev-env-create.js:173-179) and never starts anything. vip-next defaulted +// --start to true, and Start escalates to `sudo /bin/sh` to edit /etc/hosts and +// install a CA, so a CI script that used create as a cheap file-writing step +// pulled images, mutated the system trust store, and could block forever on a +// sudo prompt. The FLAG stays (it is registered vip-next surface); the DEFAULT +// must match Node. +func TestResolveCreateConfigStartDefaultsFalse(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x"}); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Start { + t.Fatal("create must not start (and must not reach sudo) unless --start is passed") + } +} + +// The flag itself must survive — it is registered vip-next surface (cutover +// register section 4), so `--start` still starts. +func TestResolveCreateConfigStartFlagStillWorks(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x", "--start"}); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if !cfg.Start { + t.Fatal("--start must still start the environment") + } +} + +// Register 2.21 for create: Node resolves create's slug through the same +// getEnvironmentName, so a configured repo creates the CONFIGURED environment, +// not "vip-local". Without this, create and start/destroy target different +// environments in the same repo. +func TestResolveCreateConfigUsesConfigurationFileSlug(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + t.Setenv("XDG_DATA_HOME", t.TempDir()) + repo := t.TempDir() + writeDevEnvConfig(t, repo, "configured-site") + t.Chdir(repo) + + c := devEnvCreateCmd() + if err := c.Flags().Parse(nil); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Slug != "configured-site" { + t.Fatalf("Slug = %q, want configured-site from .wpvip/vip-dev-env.yml", cfg.Slug) + } +} + +// Node rejects an unsupported PHP version up front, in promptForArguments, +// BEFORE createEnvironment writes anything (resolvePhpVersion, +// dev-environment-cli.ts:778-782). vip-next passed any bare version straight +// through to the image name, so a typo only surfaced as an opaque +// `docker pull` failure AFTER the environment was already on disk. +func TestResolveCreateConfigRejectsUnsupportedPHPVersion(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x", "--php", "8.9"}); err != nil { + t.Fatal(err) + } + _, err := resolveCreateConfig(c, nil) + if err == nil { + t.Fatal("an unsupported PHP version must be rejected before the environment is written") + } + if want := "Unknown or unsupported PHP version: 8.9."; err.Error() != want { + t.Errorf("error = %q, want %q", err, want) + } +} + +func TestResolveCreateConfigAcceptsSupportedPHPVersions(t *testing.T) { + for _, v := range []string{"8.2", "8.3", "8.4", "8.5"} { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x", "--php", v}); err != nil { + t.Fatal(err) + } + if _, err := resolveCreateConfig(c, nil); err != nil { + t.Errorf("PHP %s must be accepted: %v", v, err) + } + } +} + +// An explicit image reference stays a deliberate vip-next superset (Node only +// accepts the four canonical image strings). Validation targets the typo case, +// not the escape hatch. +func TestResolveCreateConfigAcceptsExplicitPHPImage(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x", "--php", "ghcr.io/automattic/vip-container-images/php-fpm:8.3"}); err != nil { + t.Fatal(err) + } + if _, err := resolveCreateConfig(c, nil); err != nil { + t.Errorf("explicit image references must keep working: %v", err) + } +} + +// --slug still beats the configuration file on create. +func TestResolveCreateConfigFlagSlugBeatsConfigurationFile(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + t.Setenv("XDG_DATA_HOME", t.TempDir()) + repo := t.TempDir() + writeDevEnvConfig(t, repo, "configured-site") + t.Chdir(repo) + + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "explicit"}); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Slug != "explicit" { + t.Fatalf("Slug = %q, want explicit", cfg.Slug) + } +} + +func TestResolveCreateConfigAppDefaultsSeedConfig(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "with-app"}); err != nil { + t.Fatal(err) + } + defaults := &createDefaults{ + Title: "cantina-trunk-staging", Multisite: true, PHP: "8.2", + WordPress: "6.4", MediaRedirectDomain: "cantina.example.com", + } + cfg, err := resolveCreateConfig(c, defaults) + if err != nil { + t.Fatal(err) + } + if cfg.Title != "cantina-trunk-staging" { + t.Fatalf("Title = %q, want app env name", cfg.Title) + } + if cfg.MultisiteMode != "subdomain" { + t.Fatalf("MultisiteMode = %q, want subdomain (app is multisite)", cfg.MultisiteMode) + } + if cfg.PHP != "8.2" || cfg.WordPress != "6.4" { + t.Fatalf("php/wordpress defaults not applied: %+v", cfg) + } + if cfg.MediaDomain != "cantina.example.com" { + t.Fatalf("MediaDomain = %q, want app primary domain", cfg.MediaDomain) + } +} + +// A passed flag still wins over an @app.env default. +func TestResolveCreateConfigFlagBeatsAppDefault(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := devEnvCreateCmd() + if err := c.Flags().Parse([]string{"--slug", "x", "--title", "Override"}); err != nil { + t.Fatal(err) + } + cfg, err := resolveCreateConfig(c, &createDefaults{Title: "from-app"}) + if err != nil { + t.Fatal(err) + } + if cfg.Title != "Override" { + t.Fatalf("Title = %q, want flag to win", cfg.Title) + } +} + +func TestResolveCreateConfigFlagsWin(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + // Routed through parseDevEnv (the production argv path) because + // --multisite is now one of Node's optional-value options: the + // space-separated `--multisite subdirectory` form is reassembled by the + // optional-value normalizer, not by pflag. + c := parseDevEnv(t, + "create", "--slug", "y", "--title", "My Site", "--multisite", "subdirectory", + "--php", "8.4", "--wordpress", "6.5", "--phpmyadmin", "--xdebug", + "--start=false", + ) + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Title != "My Site" || cfg.MultisiteMode != "subdirectory" || cfg.PHP != "8.4" || cfg.WordPress != "6.5" { + t.Fatalf("flags not honored: %+v", cfg) + } + if !cfg.PHPMyAdmin || !cfg.Xdebug { + t.Fatalf("bool flags not honored: %+v", cfg) + } + if cfg.Start { + t.Fatal("--start=false not honored") + } +} + +func TestPHPVersionForLabel(t *testing.T) { + if phpVersionForLabel("8.2 (recommended)") != "8.2" { + t.Fatal("recommended label should map to 8.2") + } + if phpVersionForLabel("8.5 (experimental)") != "8.5" { + t.Fatal("experimental label should map to 8.5") + } + if phpVersionForLabel("8.4") != "8.4" { + t.Fatal("plain label should map to its version") + } +} + +func TestParseWordPressTags(t *testing.T) { + body := []byte(`[ + {"ref":"7.0","tag":"7.0","prerelease":false}, + {"ref":"6.9.4","tag":"6.9","prerelease":false}, + {"ref":"6.9.3","tag":"6.9","prerelease":false}, + {"ref":"","tag":"","prerelease":false} + ]`) + got := parseWordPressTags(body) + want := []string{"7.0", "6.9"} // deduped, blank dropped, manifest order kept + if len(got) != len(want) { + t.Fatalf("parseWordPressTags = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parseWordPressTags[%d] = %q, want %q", i, got[i], want[i]) + } + } + if parseWordPressTags([]byte("not json")) != nil { + t.Fatal("invalid JSON should yield nil (caller falls back to trunk)") + } +} diff --git a/cmd/vip-next/commands/devenv_data.go b/cmd/vip-next/commands/devenv_data.go new file mode 100644 index 000000000..f681317ec --- /dev/null +++ b/cmd/vip-next/commands/devenv_data.go @@ -0,0 +1,86 @@ +package commands + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" +) + +// devenvImportSQL is the seam tests replace to observe the ImportOptions the +// cobra layer builds without touching Docker. +var devenvImportSQL = devenv.ImportSQL + +func newDevEnvImportCmd() *cobra.Command { + imp := &cobra.Command{Use: "import", Short: "Import data into a local environment"} + imp.AddCommand(newDevEnvImportSQLCmd(), newDevEnvImportMediaCmd()) + return imp +} + +func newDevEnvImportSQLCmd() *cobra.Command { + var searchReplace []string + var inPlace, skipValidate, skipReindex, quiet bool + c := &cobra.Command{ + Use: "sql ", + Short: "Import a SQL file into a local environment", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "" { + return errors.New("you must pass a SQL file path") + } + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + return devenvImportSQL(cmd.Context(), slug, args[0], devenv.ImportOptions{ + SearchReplace: searchReplace, + InPlace: inPlace, + SkipValidate: skipValidate, + SkipReindex: skipReindex, + Quiet: quiet, + Out: cmd.OutOrStdout(), + // Hand the cobra command down so the irreversible --in-place + // gate honours the --non-interactive FLAG, not just + // VIP_NON_INTERACTIVE. internal/devenv has no command of its + // own to give appctx. + Confirm: func(message string, defaultYes bool) (bool, error) { + return appctx.Confirm(cmd, message, defaultYes) + }, + }) + }, + } + addSlugFlag(c) + c.Flags().StringArrayVarP(&searchReplace, "search-replace", "r", nil, `"from,to" replacement applied during import (repeatable).`) + c.Flags().BoolVarP(&inPlace, "in-place", "i", false, "Search-replace the source SQL file in place (saves the changes).") + // One flag, two effects — Node's own grouping (dev-env-import-sql.ts:83). + c.Flags().BoolVar(&skipValidate, "skip-validate", false, "Skip the SQL file validation and the running-environment check.") + c.Flags().BoolVarP(&skipReindex, "skip-reindex", "k", false, "Skip the Elasticsearch reindex after import.") + c.Flags().BoolVarP(&quiet, "quiet", "q", false, "Skip confirmation and suppress informational messages.") + return c +} + +func newDevEnvImportMediaCmd() *cobra.Command { + c := &cobra.Command{ + Use: "media ", + Short: "Import media files into a local environment", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "" { + return errors.New("you must pass a media directory path") + } + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + return devenv.ImportMedia(cmd.Context(), slug, args[0]) + }, + } + addSlugFlag(c) + return c +} diff --git a/cmd/vip-next/commands/devenv_data_confirm_test.go b/cmd/vip-next/commands/devenv_data_confirm_test.go new file mode 100644 index 000000000..ccd30018f --- /dev/null +++ b/cmd/vip-next/commands/devenv_data_confirm_test.go @@ -0,0 +1,49 @@ +package commands + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/Automattic/vip/internal/devenv" +) + +// Slice-3 left `dev-env import sql --in-place` calling appctx.Confirm(nil, …), +// so the in-place gate saw VIP_NON_INTERACTIVE but never the --non-interactive +// FLAG: on a TTY, `--non-interactive` still stopped to ask. The cobra command +// must hand its own *cobra.Command down so the flag is honoured. +func TestDevEnvImportSQLInPlacePassesNonInteractiveFlag(t *testing.T) { + src := filepath.Join(t.TempDir(), "dump.sql") + if err := os.WriteFile(src, []byte("-- MySQL dump\n"), 0o600); err != nil { + t.Fatal(err) + } + + var sawCmd bool + prev := devenvImportSQL + devenvImportSQL = func(_ context.Context, _, _ string, o devenv.ImportOptions) error { + if o.Confirm == nil { + t.Error("dev-env import sql must inject a cobra-aware Confirm so --non-interactive is honoured") + return nil + } + // The injected confirm must consult the command's flags. With + // --non-interactive set it has to refuse instead of prompting. + _, err := o.Confirm("Are you sure?", false) + sawCmd = err != nil + return nil + } + defer func() { devenvImportSQL = prev }() + + cmd := newDevEnvImportSQLCmd() + _ = cmd.Flags().Set("slug", "e") + cmd.Flags().Bool("non-interactive", true, "") + _ = cmd.Flags().Set("non-interactive", "true") + cmd.SetContext(context.Background()) + + if err := cmd.RunE(cmd, []string{src}); err != nil { + t.Fatalf("RunE: %v", err) + } + if !sawCmd { + t.Error("--non-interactive must make the in-place confirm refuse, not prompt") + } +} diff --git a/cmd/vip-next/commands/devenv_e2e_gate_test.go b/cmd/vip-next/commands/devenv_e2e_gate_test.go new file mode 100644 index 000000000..c27957fe0 --- /dev/null +++ b/cmd/vip-next/commands/devenv_e2e_gate_test.go @@ -0,0 +1,17 @@ +//go:build devenv_e2e + +package commands + +import ( + "os" + "testing" + + "github.com/Automattic/vip/internal/devenv/e2esafety" +) + +func TestMain(m *testing.M) { + if e2esafety.Skip(os.Getenv, os.Stdout) { + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/cmd/vip-next/commands/devenv_e2e_test.go b/cmd/vip-next/commands/devenv_e2e_test.go new file mode 100644 index 000000000..451e7fb0f --- /dev/null +++ b/cmd/vip-next/commands/devenv_e2e_test.go @@ -0,0 +1,34 @@ +//go:build devenv_e2e + +// Package commands devenv_e2e harness — the manual gate for the dev-env command +// cutover. Run with: go test -tags devenv_e2e -run TestDevEnvE2E ./cmd/vip-next/commands/ -v +// +// These exercise the real Docker/PTY/platform paths and need: a running Docker, +// a TTY (for exec/shell), and (for sync) `vip login`. They are skipped unless +// VIP_DEVENV_E2E=1 is set, so an accidental tagged run does not hang. +package commands + +import "testing" + +// TestDevEnvE2ELifecycle documents the create→start→exec→logs→stop→destroy path. +func TestDevEnvE2ELifecycle(t *testing.T) { + t.Skip("MANUAL ONLY: create --slug e2e --start; exec -- wp option get home; logs; stop; destroy --yes") +} + +// TestDevEnvE2EImport documents import sql/media into a running env. +func TestDevEnvE2EImport(t *testing.T) { + t.Skip("MANUAL ONLY: import sql with --search-replace; import media ; verify in container") +} + +// TestDevEnvE2EEnvVar documents envvar set → rebuild → verify-in-container. +func TestDevEnvE2EEnvVar(t *testing.T) { + t.Skip("MANUAL ONLY: envvar set MY_VAR hi; vip dev-env start; exec -- sh -c 'echo $MY_VAR'") +} + +// TestDevEnvE2ESync documents the optional real-platform smoke test. Automated +// multisite coverage uses fixture SQL/SDS in internal/devenv and never sends a +// sync/export payload to the real API. This manual check still needs explicit +// human authorization, login, and a deliberately selected app/environment. +func TestDevEnvE2ESync(t *testing.T) { + t.Skip("MANUAL ONLY WITH EXPLICIT AUTHORIZATION: vip login; vip @app.env dev-env sync sql --slug e2e; verify prod URLs rewritten locally") +} diff --git a/cmd/vip-next/commands/devenv_envvar.go b/cmd/vip-next/commands/devenv_envvar.go new file mode 100644 index 000000000..e4505c196 --- /dev/null +++ b/cmd/vip-next/commands/devenv_envvar.go @@ -0,0 +1,174 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/envvar" + "github.com/Automattic/vip/internal/output" +) + +// addEnvvarFormatFlag registers the shared --format flag for the listing envvar +// leaves (table/csv/json/ids), defaulting to table like Node. +func addEnvvarFormatFlag(c *cobra.Command, format *string) { + c.Flags().StringVarP(format, "format", "f", "table", "Render output in a particular format: table, csv, json, or ids.") +} + +func newDevEnvEnvvarCmd() *cobra.Command { + ev := &cobra.Command{Use: "envvar", Short: "Manage environment variables for a local environment"} + ev.AddCommand( + envvarGetCmd(), envvarGetAllCmd(), envvarListCmd(), envvarSetCmd(), envvarDeleteCmd(), + ) + return ev +} + +func envvarGetCmd() *cobra.Command { + c := &cobra.Command{Use: "get ", Short: "Get a variable", Args: cobra.ExactArgs(1), SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + // Node trims but does NOT validate here (vip-dev-env-envvar-get.js:33). + name := strings.TrimSpace(args[0]) + v, ok, err := devenv.EnvVarGet(slug, name) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("variable %q is not set", name) + } + fmt.Fprintln(cmd.OutOrStdout(), v) + return nil + }} + addSlugFlag(c) + return c +} + +func envvarGetAllCmd() *cobra.Command { + var format string + c := &cobra.Command{Use: "get-all", Short: "Get all variables", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + all, err := devenv.EnvVarGetAll(slug) + if err != nil { + return err + } + names, _ := devenv.EnvVarList(slug) + rows := make(output.OrderedRows, 0, len(names)) + for _, k := range names { + rows = append(rows, output.OrderedRow{{Key: "name", Value: k}, {Key: "value", Value: all[k]}}) + } + return output.Render(cmd.OutOrStdout(), output.Format(format), rows) + }} + addSlugFlag(c) + addEnvvarFormatFlag(c, &format) + return c +} + +func envvarListCmd() *cobra.Command { + var format string + c := &cobra.Command{Use: "list", Short: "List variable names", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + names, err := devenv.EnvVarList(slug) + if err != nil { + return err + } + rows := make(output.OrderedRows, 0, len(names)) + for _, k := range names { + rows = append(rows, output.OrderedRow{{Key: "name", Value: k}}) + } + return output.Render(cmd.OutOrStdout(), output.Format(format), rows) + }} + addSlugFlag(c) + addEnvvarFormatFlag(c, &format) + return c +} + +func envvarSetCmd() *cobra.Command { + var fromFile string + c := &cobra.Command{Use: "set [value]", Short: "Set a variable", Args: cobra.RangeArgs(1, 2), SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + // Node trims the name and then runs validateNameWithMessage, + // exiting 1 on failure (vip-dev-env-envvar-set.js:46,52). Without + // it a lowercase or hyphenated name lands in .env, where it is not + // a valid shell identifier and the container silently ignores it. + name := strings.TrimSpace(args[0]) + if name == "" { + return errors.New("variable name is required") + } + if err := envvar.ValidateName(name); err != nil { + return err + } + var value string + switch { + case len(args) == 2: + value = args[1] + case fromFile != "": + b, err := os.ReadFile(fromFile) + if err != nil { + return fmt.Errorf("reading --from-file %q: %w", fromFile, err) + } + // Node's readFromFile TRIMS (src/lib/read-file.ts:8). Skipping + // the trim makes a trailing newline part of the value, so an + // API token read from a file is silently wrong. + value = strings.TrimSpace(string(b)) + default: + value, err = appctx.Input(cmd, fmt.Sprintf("Value for %s", name), "") + if err != nil { + return err + } + } + if err := devenv.EnvVarSet(slug, name, value); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Set %q. Restart the environment for the change to take effect.\n", name) + return nil + }} + addSlugFlag(c) + c.Flags().StringVarP(&fromFile, "from-file", "f", "", "Read the variable value from a UTF-8 text file (useful for multiline values).") + return c +} + +func envvarDeleteCmd() *cobra.Command { + c := &cobra.Command{Use: "delete ", Short: "Delete a variable", Args: cobra.ExactArgs(1), SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + // Node trims but does NOT validate here (vip-dev-env-envvar-delete.js:32). + name := strings.TrimSpace(args[0]) + removed, err := devenv.EnvVarDelete(slug, name) + if err != nil { + return err + } + // Node exits 1 on a name that was not there + // (vip-dev-env-envvar-delete.js:51-54). Reporting success for a + // delete that removed nothing hides a typo'd name in a script. + if !removed { + return fmt.Errorf("The environment variable %q does not exist", name) + } + fmt.Fprintf(cmd.OutOrStdout(), "Deleted %q. Restart the environment for the change to take effect.\n", name) + return nil + }} + addSlugFlag(c) + return c +} diff --git a/cmd/vip-next/commands/devenv_envvar_test.go b/cmd/vip-next/commands/devenv_envvar_test.go new file mode 100644 index 000000000..9ae945d26 --- /dev/null +++ b/cmd/vip-next/commands/devenv_envvar_test.go @@ -0,0 +1,183 @@ +package commands + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// seedEnvvarEnv creates one on-disk environment so ResolveSlug finds it. +func seedEnvvarEnv(t *testing.T) { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Chdir(t.TempDir()) // no .wpvip config file in scope + if err := instancedata.Write("only-one", &instancedata.InstanceData{ + SiteSlug: "only-one", Multisite: []byte("false"), + }); err != nil { + t.Fatal(err) + } +} + +func runEnvvar(t *testing.T, args ...string) (string, error) { + t.Helper() + root := newDevEnvEnvvarCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + err := root.Execute() + return out.String(), err +} + +// Node validates every dev-env envvar name through validateNameWithMessage +// (vip-dev-env-envvar-set.js:52) and exits 1 on failure. vip-next accepted +// anything, so a lowercase or hyphenated name landed in .env where it is not a +// valid shell identifier and is silently ignored by the container. +func TestEnvvarSetRejectsInvalidNames(t *testing.T) { + for _, name := range []string{"lower_case", "MY-VAR", "1STVAR", "_LEADING", "HAS SPACE"} { + t.Run(name, func(t *testing.T) { + seedEnvvarEnv(t) + _, err := runEnvvar(t, "set", name, "value") + if err == nil { + t.Fatalf("name %q must be rejected", name) + } + if !strings.Contains(err.Error(), "must consist of A-Z, 0-9, or _") { + t.Errorf("error = %q, want Node's message", err) + } + if v, ok, _ := devenv.EnvVarGet("only-one", name); ok { + t.Errorf("invalid name was written anyway: %q", v) + } + }) + } +} + +func TestEnvvarSetAcceptsValidNames(t *testing.T) { + for _, name := range []string{"MY_VAR", "A", "X1_2"} { + t.Run(name, func(t *testing.T) { + seedEnvvarEnv(t) + if _, err := runEnvvar(t, "set", name, "value"); err != nil { + t.Fatalf("name %q must be accepted: %v", name, err) + } + }) + } +} + +// Node trims the name before validating and storing (`args[0]?.trim()`). +func TestEnvvarSetTrimsName(t *testing.T) { + seedEnvvarEnv(t) + if _, err := runEnvvar(t, "set", " MY_VAR ", "value"); err != nil { + t.Fatal(err) + } + if _, ok, _ := devenv.EnvVarGet("only-one", "MY_VAR"); !ok { + t.Error("the trimmed name should have been stored") + } +} + +// Node's readFromFile TRIMS the file contents (src/lib/read-file.ts:8). Without +// it a trailing newline becomes part of the value, so an API token read from a +// file is silently wrong. +func TestEnvvarSetFromFileTrims(t *testing.T) { + seedEnvvarEnv(t) + f := filepath.Join(t.TempDir(), "token.txt") + if err := os.WriteFile(f, []byte(" sk-secret-token\n\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := runEnvvar(t, "set", "API_TOKEN", "--from-file", f); err != nil { + t.Fatal(err) + } + v, ok, err := devenv.EnvVarGet("only-one", "API_TOKEN") + if err != nil || !ok { + t.Fatalf("not set: %v %v", ok, err) + } + if v != "sk-secret-token" { + t.Errorf("value = %q, want the trimmed token", v) + } +} + +// `get` and `delete` do NOT validate in Node (only `set` calls +// validateNameWithMessage) — they just trim and report "does not exist". +// Matched deliberately: adding validation there would reject names a user could +// legitimately have on disk from an older CLI. +func TestEnvvarGetAndDeleteTrimButDoNotValidate(t *testing.T) { + seedEnvvarEnv(t) + if _, err := runEnvvar(t, "set", "MY_VAR", "value"); err != nil { + t.Fatal(err) + } + if _, err := runEnvvar(t, "get", " MY_VAR "); err != nil { + t.Errorf("get should trim the name: %v", err) + } + if _, err := runEnvvar(t, "delete", " MY_VAR "); err != nil { + t.Errorf("delete should trim the name: %v", err) + } + if _, ok, _ := devenv.EnvVarGet("only-one", "MY_VAR"); ok { + t.Error("delete with a padded name should have removed the variable") + } +} + +// Node EXITS 1 when the variable does not exist: it writes +// `The environment variable "" does not exist` to stderr and sets +// process.exitCode = 1, and — note — never calls updateEnvFile, so .env is left +// byte-for-byte alone (src/bin/vip-dev-env-envvar-delete.js:51-54). +// +// vip-next printed "Deleted …" and exited 0, so a typo'd variable name in a CI +// script reported a successful delete that never happened. +func TestEnvvarDeleteMissingVariableExitsNonZero(t *testing.T) { + seedEnvvarEnv(t) + if _, err := runEnvvar(t, "set", "KEEP_ME", "value"); err != nil { + t.Fatal(err) + } + envFile := filepath.Join(paths.EnvironmentPath("only-one"), ".env") + before, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + + out, err := runEnvvar(t, "delete", "NOT_THERE") + if err == nil { + t.Fatal("deleting a variable that does not exist must fail (Node exits 1)") + } + if !strings.Contains(err.Error(), `The environment variable "NOT_THERE" does not exist`) { + t.Errorf("err = %q, want Node's does-not-exist message", err) + } + if strings.Contains(out, "Deleted") { + t.Errorf("a failed delete must not claim success; output = %q", out) + } + + // The delete demonstrably did not happen: .env is untouched and the other + // variable is intact. + after, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Errorf(".env was rewritten on a no-op delete:\n before %q\n after %q", before, after) + } + if v, ok, _ := devenv.EnvVarGet("only-one", "KEEP_ME"); !ok || v != "value" { + t.Errorf("unrelated variable was disturbed: %q ok=%v", v, ok) + } +} + +// Guard against over-correcting the fix above: a delete that DOES remove +// something still succeeds, prints the success line, and drops the variable. +func TestEnvvarDeleteExistingVariableSucceeds(t *testing.T) { + seedEnvvarEnv(t) + if _, err := runEnvvar(t, "set", "GONE_SOON", "value"); err != nil { + t.Fatal(err) + } + out, err := runEnvvar(t, "delete", "GONE_SOON") + if err != nil { + t.Fatalf("deleting an existing variable must succeed: %v", err) + } + if !strings.Contains(out, "Deleted") { + t.Errorf("output = %q, want the success line", out) + } + if _, ok, _ := devenv.EnvVarGet("only-one", "GONE_SOON"); ok { + t.Error("the variable is still set after a successful delete") + } +} diff --git a/cmd/vip-next/commands/devenv_exec.go b/cmd/vip-next/commands/devenv_exec.go new file mode 100644 index 000000000..62cac1955 --- /dev/null +++ b/cmd/vip-next/commands/devenv_exec.go @@ -0,0 +1,75 @@ +package commands + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/devenv" +) + +// argsAfterDashes returns the args following the cobra `--` terminator. cobra +// stores them via ArgsLenAtDash; everything at/after that index is post-`--`. +func argsAfterDashes(cmd *cobra.Command, args []string) []string { + n := cmd.ArgsLenAtDash() + if n < 0 { + return nil + } + return args[n:] +} + +func newDevEnvExecCmd() *cobra.Command { + c := &cobra.Command{ + Use: "exec", + Short: "Run a WP-CLI command against a local environment", + Long: "Run a WP-CLI command. A double dash (\"--\") must separate vip args from the wp command:\n vip dev-env exec --slug=example -- wp post list", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + wpArgs := argsAfterDashes(cmd, args) + if len(wpArgs) == 0 { + return errors.New(`a double dash ("--") must separate vip args from the wp command; run "vip dev-env exec --help"`) + } + return devenv.Exec(cmd.Context(), slug, wpArgs) + }, + } + addSlugFlag(c) + // --force/--quiet are registered for Node flag-parity. The behaviors they + // gate (the pre-exec running-env check that --force skips, and --quiet's + // message suppression) are part of the devenv_e2e runtime path and are not + // wired in the Docker-free cutover; they are accepted but currently no-ops. + // TODO(devenv): honor --force/--quiet when the running-env check lands. + c.Flags().BoolP("force", "f", false, "Skip the running-environment check.") + c.Flags().BoolP("quiet", "q", false, "Suppress informational messages.") + return c +} + +func newDevEnvShellCmd() *cobra.Command { + var root bool + var service string + c := &cobra.Command{ + Use: "shell", + Short: "Open a shell in a local environment", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + svc := service + if svc == "" { + svc = "php" + } + return devenv.Shell(cmd.Context(), slug, svc, root, argsAfterDashes(cmd, args)) + }, + } + addSlugFlag(c) + c.Flags().BoolVarP(&root, "root", "r", false, "Open the shell with root privileges.") + c.Flags().StringVar(&service, "service", "", "Restrict to a single service (default php).") + return c +} diff --git a/cmd/vip-next/commands/devenv_exec_test.go b/cmd/vip-next/commands/devenv_exec_test.go new file mode 100644 index 000000000..fb28fb843 --- /dev/null +++ b/cmd/vip-next/commands/devenv_exec_test.go @@ -0,0 +1,39 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestArgsAfterDashes(t *testing.T) { + // Build a command and parse args containing a `--` terminator. + c := &cobra.Command{Use: "exec", RunE: func(*cobra.Command, []string) error { return nil }} + c.Flags().String("slug", "", "") + if err := c.ParseFlags([]string{"--slug", "x", "--", "wp", "post", "list"}); err != nil { + t.Fatal(err) + } + // After ParseFlags, c.Flags().Args() holds the positional args and + // ArgsLenAtDash marks the `--` split. Simulate cobra's RunE args. + args := c.Flags().Args() + got := argsAfterDashes(c, args) + want := []string{"wp", "post", "list"} + if len(got) != len(want) { + t.Fatalf("argsAfterDashes = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("argsAfterDashes[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestArgsAfterDashesNoDash(t *testing.T) { + c := &cobra.Command{Use: "exec"} + if err := c.ParseFlags([]string{"foo", "bar"}); err != nil { + t.Fatal(err) + } + if got := argsAfterDashes(c, c.Flags().Args()); got != nil { + t.Fatalf("argsAfterDashes without `--` should be nil, got %v", got) + } +} diff --git a/cmd/vip-next/commands/devenv_flag_grammar_test.go b/cmd/vip-next/commands/devenv_flag_grammar_test.go new file mode 100644 index 000000000..688820d2a --- /dev/null +++ b/cmd/vip-next/commands/devenv_flag_grammar_test.go @@ -0,0 +1,241 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/nodeflags" +) + +// parseDevEnv drives argv through the SAME path production uses: the +// optional-value normalizer, then cobra's own command resolution and flag +// parser. Asserting on a coercion helper alone would pass even while `-p n` +// still enabled the service, which is the bug this file exists to pin. +func parseDevEnv(t *testing.T, argv ...string) *cobra.Command { + t.Helper() + root := DevEnvCmd() + normalized := nodeflags.NormalizeOptionalValues(root, argv) + c, rest, err := root.Find(normalized) + if err != nil { + t.Fatalf("find %q: %v", argv, err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatalf("parse %q: %v", argv, err) + } + return c +} + +// Node shorts for the dev-env service toggles, derived by +// createOptionDefinition (src/lib/cli/command.js:62-82) over the registration +// order in addDevEnvConfigurationOptions +// (src/lib/dev-environment/dev-environment-cli.ts:1012-1081). +var devEnvServiceShorts = map[string]string{ + "phpmyadmin": "p", + "xdebug": "x", + "elasticsearch": "e", + "cron": "c", + "mailpit": "A", + "photon": "H", +} + +type serviceFlagCase struct { + args []string + want bool +} + +func serviceFlagCases(svc, short string) []serviceFlagCase { + return []serviceFlagCase{ + // processBooleanOption: FALSE_OPTIONS = false, everything else true. + {[]string{"--" + svc, "n"}, false}, + {[]string{"--" + svc, "no"}, false}, + {[]string{"--" + svc, "false"}, false}, + {[]string{"--" + svc, "0"}, false}, + {[]string{"--" + svc, "N"}, false}, + {[]string{"--" + svc + "=n"}, false}, + {[]string{"--" + svc + "=false"}, false}, + {[]string{"-" + short, "n"}, false}, + {[]string{"-" + short + "=n"}, false}, + {[]string{"-" + short + "n"}, false}, + // Bare flag => enabled (commander fills an omitted optional value + // with `true`; Node's help documents "y" as the default value). + {[]string{"--" + svc}, true}, + {[]string{"-" + short}, true}, + {[]string{"--" + svc, "y"}, true}, + {[]string{"--" + svc, "yes"}, true}, + {[]string{"--" + svc, "1"}, true}, + {[]string{"--" + svc + "=true"}, true}, + {[]string{"-" + short, "y"}, true}, + // Unrecognized values are TRUE in Node, not an error. + {[]string{"--" + svc, "maybe"}, true}, + } +} + +func TestDevEnvCreateServiceFlagsUseNodeYNGrammar(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + for svc, short := range devEnvServiceShorts { + for _, tc := range serviceFlagCases(svc, short) { + argv := append([]string{"create"}, tc.args...) + c := parseDevEnv(t, argv...) + got, err := resolveCreateBool(c, svc, "") + if err != nil { + t.Fatalf("%q: %v", argv, err) + } + if got != tc.want { + t.Errorf("dev-env %q => %s = %v, want %v", argv, svc, got, tc.want) + } + // The value token must be consumed as the flag's value, not left + // dangling as a positional (the old bool flags swallowed it). + if n := len(c.Flags().Args()); n != 0 { + t.Errorf("dev-env %q left %d stray positional(s): %q", argv, n, c.Flags().Args()) + } + } + } +} + +func TestDevEnvUpdateServiceFlagsUseNodeYNGrammar(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + for svc, short := range devEnvServiceShorts { + for _, tc := range serviceFlagCases(svc, short) { + argv := append([]string{"update"}, tc.args...) + c := parseDevEnv(t, argv...) + // current=true so a nil return (leave unchanged) cannot masquerade + // as a correct `false`. + got, err := resolveUpdateBool(c, svc, "", true) + if err != nil { + t.Fatalf("%q: %v", argv, err) + } + if got == nil { + t.Fatalf("dev-env %q: %s not applied", argv, svc) + } + if *got != tc.want { + t.Errorf("dev-env %q => %s = %v, want %v", argv, svc, *got, tc.want) + } + } + } +} + +// Node: processMediaRedirectDomainOption (dev-environment-cli.ts:948-961). +func TestDevEnvMediaRedirectDomainGrammar(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + for _, cmdName := range []string{"create", "update"} { + for _, disable := range []string{"n", "no", "false", "0", "N"} { + c := parseDevEnv(t, cmdName, "--media-redirect-domain", disable) + got, err := devEnvMediaRedirectDomain(c) + if err != nil { + t.Fatalf("%s --media-redirect-domain %s: %v", cmdName, disable, err) + } + if got != "" { + t.Errorf("%s --media-redirect-domain %s = %q, want \"\" (disabled)", cmdName, disable, got) + } + } + for _, truthy := range []string{"y", "yes", "true", "1"} { + c := parseDevEnv(t, cmdName, "-r", truthy) + if _, err := devEnvMediaRedirectDomain(c); err == nil { + t.Errorf("%s -r %s: want an error, got nil", cmdName, truthy) + } + } + c := parseDevEnv(t, cmdName, "-r", "example.go-vip.co") + got, err := devEnvMediaRedirectDomain(c) + if err != nil || got != "example.go-vip.co" { + t.Errorf("%s -r example.go-vip.co = (%q, %v)", cmdName, got, err) + } + } +} + +// Node: every dev-env bin that registers --slug passes processSlug +// (dev-environment-cli.ts:979) as the option's parse function. +func TestDevEnvSlugIsLowercased(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := parseDevEnv(t, "start", "--slug", "Example-Site") + got, err := ResolveSlug(c) + if err != nil { + t.Fatal(err) + } + if got != "example-site" { + t.Errorf("ResolveSlug = %q, want %q", got, "example-site") + } + + cc := parseDevEnv(t, "create", "-s", "MixedCase") + cfg, err := resolveCreateConfig(cc, nil) + if err != nil { + t.Fatal(err) + } + if cfg.Slug != "mixedcase" { + t.Errorf("create slug = %q, want %q", cfg.Slug, "mixedcase") + } +} + +// Node names this option with an UNDERSCORE (dev-environment-cli.ts:1042). +func TestDevEnvXdebugConfigUnderscoreForm(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + for _, cmdName := range []string{"create", "update"} { + for _, flag := range []string{"--xdebug_config", "--xdebug-config"} { + c := parseDevEnv(t, cmdName, flag, "idekey=vip") + cfg, err := devEnvXdebugConfig(c) + if err != nil { + t.Fatalf("%s %s: %v", cmdName, flag, err) + } + if cfg != "idekey=vip" { + t.Errorf("%s %s => %q", cmdName, flag, cfg) + } + } + } +} + +// Node: processComponentOptionInput (dev-environment-cli.ts:237) — a value +// with no path separator is an IMAGE reference, not a bind-mount path. +func TestDevEnvCreateAppCodeDemoIsNotAPath(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := parseDevEnv(t, "create", "--app-code", "demo", "--mu-plugins", "demo") + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatal(err) + } + if cfg.AppCodeDir != "" { + t.Errorf("--app-code demo => AppCodeDir %q, want \"\" (image mode)", cfg.AppCodeDir) + } + if cfg.MuPluginsDir != "" { + t.Errorf("--mu-plugins demo => MuPluginsDir %q, want \"\" (image mode)", cfg.MuPluginsDir) + } + + local := parseDevEnv(t, "create", "--app-code", "/tmp/repo") + lcfg, err := resolveCreateConfig(local, nil) + if err != nil { + t.Fatal(err) + } + if lcfg.AppCodeDir != "/tmp/repo" { + t.Errorf("--app-code /tmp/repo => AppCodeDir %q", lcfg.AppCodeDir) + } +} + +// Node: --multisite uses processStringOrBooleanOption and is an optional-value +// option whose documented bare default is "y" (subdomain). +func TestDevEnvCreateMultisiteGrammar(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cases := []struct { + args []string + want string + }{ + {[]string{"--multisite"}, "subdomain"}, + {[]string{"-m"}, "subdomain"}, + {[]string{"--multisite", "y"}, "subdomain"}, + {[]string{"--multisite", "1"}, "subdomain"}, + {[]string{"--multisite=true"}, "subdomain"}, + {[]string{"--multisite", "subdirectory"}, "subdirectory"}, + {[]string{"--multisite=subdirectory"}, "subdirectory"}, + {[]string{"--multisite", "n"}, ""}, + {[]string{"--multisite", "false"}, ""}, + {[]string{"--multisite", "0"}, ""}, + } + for _, tc := range cases { + c := parseDevEnv(t, append([]string{"create"}, tc.args...)...) + cfg, err := resolveCreateConfig(c, nil) + if err != nil { + t.Fatalf("%q: %v", tc.args, err) + } + if cfg.MultisiteMode != tc.want { + t.Errorf("create %q => multisite %q, want %q", tc.args, cfg.MultisiteMode, tc.want) + } + } +} diff --git a/cmd/vip-next/commands/devenv_lifecycle.go b/cmd/vip-next/commands/devenv_lifecycle.go new file mode 100644 index 000000000..9c49bd426 --- /dev/null +++ b/cmd/vip-next/commands/devenv_lifecycle.go @@ -0,0 +1,759 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/devlog" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/httpproxy" + "github.com/Automattic/vip/internal/nodeflags" +) + +// openDevEnvLog opens a per-env, per-invocation session log, points its footer +// at stdout, and returns a context carrying the logger plus a finish func that +// prints the "COMMAND LOG FILE" footer and closes the file. Best-effort: on +// failure it returns the command's context and a no-op finish, so logging never +// blocks the command. Callers should `defer finish()` so the footer prints last +// (after the info table), matching Node's on-exit log-path banner. +// +// When creating is false the env must already exist; otherwise we skip logging +// rather than scaffold a logs/ directory for a bogus slug (which AllNames would +// then surface as a phantom environment). create passes creating=true because +// the environment is about to be written. +func openDevEnvLog(cmd *cobra.Command, slug string, creating bool) (context.Context, func()) { + if !creating && !instancedata.Exists(slug) { + return cmd.Context(), func() {} + } + l, err := devlog.Open(slug) + if err != nil { + return cmd.Context(), func() {} + } + l.SetFooterWriter(cmd.OutOrStdout()) + return devlog.WithLogger(cmd.Context(), l), func() { + l.Finish() + _ = l.Close() + } +} + +func devEnvCreateCmd() *cobra.Command { + c := &cobra.Command{ + Use: "create", + Short: "Create a new local environment", + SilenceUsage: true, + SilenceErrors: true, + RunE: runDevEnvCreate, + } + f := c.Flags() + addXdebugConfigAlias(c) + f.StringP("slug", "s", "", "A unique name for the new local environment.") + f.StringP("title", "t", "", "WordPress Site Title.") + f.StringP("multisite", "m", "", `Create the environment as a multisite. Accepts "y" (default value) for a subdomain multisite, "subdirectory", or "n".`) + f.String("php", "", "PHP image/version.") + f.StringP("wordpress", "w", "", "WordPress version tag.") + f.StringP("mu-plugins", "u", "", `Source for VIP MU plugins. Accepts "demo" (default) or a local path.`) + f.StringP("app-code", "a", "", `Source for application code. Accepts "demo" (default) or a local path.`) + addDevEnvServiceFlags(c) + f.StringP("media-redirect-domain", "r", "", `Proxy media from a VIP Platform environment. Accepts a domain, or "n" to disable.`) + // New environments pin compose.DefaultDomain ("vipdev.site"); only + // pre-switch/Lando-adopted environments keep vipdev.lndo.site, so the help + // text must not still promise the legacy domain. + f.String("domain", "", "Custom domain (empty = "+compose.DefaultDomain+").") + // Defaults FALSE, matching Node: `dev-env create` only writes files and + // then prints "To start the environment run: vip dev-env start" + // (vip-dev-env-create.js:173-179). Starting reaches sudo (/etc/hosts + CA + // trust store, internal/devenv/hostops), which a create must never do + // implicitly — it hangs CI on a sudo prompt. The flag itself is registered + // vip-next surface and stays. See cutover register item 2.22. + f.Bool("start", false, "Start the environment after creating it (Node's create only writes files).") + // --multisite is one of Node's optional-value options: the bare form means + // "y" (dev-environment-cli.ts:1012 block registers it with + // processStringOrBooleanOption and no boolean default). + nodeflags.MarkOptionalValue(c, "y", "multisite") + return c +} + +// devEnvServiceNames are the six y/n service toggles Node registers in +// addDevEnvConfigurationOptions (dev-environment-cli.ts:1012-1081), with the +// short aliases createOptionDefinition derives for them. +var devEnvServiceNames = []struct{ Name, Short, Usage string }{ + {"phpmyadmin", "p", `Enable or disable phpMyAdmin, disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, + {"xdebug", "x", `Enable or disable XDebug, disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, + {"elasticsearch", "e", `Enable or disable Elasticsearch (required by Enterprise Search), disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, + {"cron", "c", `Enable or disable cron, disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, + {"mailpit", "A", `Enable or disable Mailpit, disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, + {"photon", "H", `Enable or disable Photon, disabled by default. Accepts "y" (default value) to enable or "n" to disable.`}, +} + +// addDevEnvServiceFlags registers the service toggles with Node's grammar: +// STRING flags with an omitted-value default of "y", coerced through +// processBooleanOption. Registering them as cobra bools inverted them — +// pflag's NoOptDefVal="true" made `-p n` ENABLE the service and dropped the +// "n", and `--phpmyadmin=n` was a hard strconv.ParseBool error. +func addDevEnvServiceFlags(c *cobra.Command) { + names := make([]string, 0, len(devEnvServiceNames)) + for _, s := range devEnvServiceNames { + c.Flags().StringP(s.Name, s.Short, "", s.Usage) + names = append(names, s.Name) + } + nodeflags.MarkOptionalValue(c, "y", names...) +} + +// addXdebugConfigAlias registers Node's underscored `--xdebug_config` +// (dev-environment-cli.ts:1042) as the canonical name and keeps vip-next's +// earlier `--xdebug-config` spelling working as an alias. +func addXdebugConfigAlias(c *cobra.Command) { + c.Flags().String("xdebug_config", "", "Override some default configuration settings for Xdebug. Accepts a string value that is assigned to the XDEBUG_CONFIG environment variable.") + aliasFlagName(c, "xdebug-config", "xdebug_config") +} + +// devEnvServiceFlag coerces a parsed y/n service toggle through Node's +// processBooleanOption. +func devEnvServiceFlag(cmd *cobra.Command, name string) bool { + v, _ := cmd.Flags().GetString(name) + return nodeflags.ProcessBooleanOption(v) +} + +// devEnvMediaRedirectDomain applies processMediaRedirectDomainOption +// (dev-environment-cli.ts:948): "n"/"no"/"false"/"0" DISABLE the proxy, +// "y"/"yes"/"true"/"1" are a user error, anything else is the domain. +func devEnvMediaRedirectDomain(cmd *cobra.Command) (string, error) { + raw, _ := cmd.Flags().GetString("media-redirect-domain") + return nodeflags.ProcessMediaRedirectDomainOption(raw) +} + +// devEnvXdebugConfig reads --xdebug_config (or its --xdebug-config alias). +func devEnvXdebugConfig(cmd *cobra.Command) (string, error) { + return cmd.Flags().GetString("xdebug_config") +} + +// devEnvComponentDir applies processComponentOptionInput +// (dev-environment-cli.ts:237) to --app-code / --mu-plugins: only a value +// containing a path separator is a local directory. "demo", "image" and any +// other bare word select an image, so they must NOT become bind-mount paths. +func devEnvComponentDir(cmd *cobra.Command, name string) string { + raw, _ := cmd.Flags().GetString(name) + return nodeflags.ProcessComponentOptionInput(raw, true).Dir +} + +// devEnvWizardIntro mirrors Node's DEV_ENVIRONMENT_PROMPT_INTRO. +const devEnvWizardIntro = "This is a wizard to help you set up your local dev environment.\n\n" + + "Sensible defaults are pre-selected; press Enter to accept each one. Pass the\n" + + "matching flags (or --non-interactive) to skip the wizard, and use --slug to\n" + + "create multiple environments with different settings.\n\n" + +// devEnvPHPChoices are the offered PHP versions with Node's labels +// (DEV_ENVIRONMENT_PHP_VERSIONS); the value is the bare version, which NewView +// resolves to the php-fpm image. The first entry is the recommended default. +var devEnvPHPChoices = []struct{ Label, Version string }{ + {"8.2 (recommended)", "8.2"}, + {"8.3", "8.3"}, + {"8.4", "8.4"}, + {"8.5 (experimental)", "8.5"}, +} + +// validatePHPVersion ports resolvePhpVersion's rejection +// (dev-environment-cli.ts:778-782): an unsupported version must fail BEFORE the +// environment is written, otherwise a typo only surfaces later as an opaque +// `docker pull` failure with the environment already on disk. +// +// DELIBERATE SUPERSET: Node accepts only the four bare versions and their four +// canonical image strings. vip-next additionally lets an explicit image +// reference through verbatim (compose.phpImage's escape hatch), so validation +// applies to bare versions only — the typo case — and does not take away +// something that already worked. +func validatePHPVersion(php string) error { + if php == "" || strings.ContainsAny(php, "/:") { + return nil + } + for _, c := range devEnvPHPChoices { + if c.Version == php { + return nil + } + } + return fmt.Errorf("Unknown or unsupported PHP version: %s.", php) +} + +// phpLabels returns the wizard PHP choice labels in order. +func phpLabels() []string { + labels := make([]string, len(devEnvPHPChoices)) + for i, c := range devEnvPHPChoices { + labels[i] = c.Label + } + return labels +} + +// phpVersionForLabel maps a wizard PHP label back to its bare version. +func phpVersionForLabel(label string) string { + for _, c := range devEnvPHPChoices { + if c.Label == label { + return c.Version + } + } + return label +} + +// phpLabelForVersion maps a bare PHP version (or image tag) to its wizard label, +// or "" if unknown. +func phpLabelForVersion(version string) string { + for _, c := range devEnvPHPChoices { + if c.Version == version { + return c.Label + } + } + return "" +} + +// selectWithDefault prompts with options, moving dflt to the front so it is the +// pre-selected default when present; otherwise the first option is the default. +func selectWithDefault(cmd *cobra.Command, message string, options []string, dflt string) (string, error) { + if dflt != "" { + reordered := make([]string, 0, len(options)) + found := false + for _, o := range options { + if o == dflt { + found = true + break + } + } + if found { + reordered = append(reordered, dflt) + for _, o := range options { + if o != dflt { + reordered = append(reordered, o) + } + } + options = reordered + } + } + return appctx.Select(cmd, message, options) +} + +// wordpressVersionsURL is the container-images version manifest the wizard +// lists. Node builds the same URL from DEV_ENVIRONMENT_RAW_GITHUB_HOST + +// DEV_ENVIRONMENT_WORDPRESS_VERSIONS_URI in fetchVersionList +// (dev-environment-core.ts:1040). A var, not a const, so the proxy-policy test +// can point it at a local server. +var wordpressVersionsURL = "https://raw.githubusercontent.com/Automattic/vip-container-images/master/wordpress/versions.json" + +// wordpressVersionChoices returns the WordPress versions to offer in the wizard: +// "trunk" (the default) first, then the tags from the manifest. A failed/slow +// fetch degrades to just trunk so the wizard never blocks on the network. +func wordpressVersionChoices() []string { + choices := []string{"trunk"} + // Node hands exactly this request to createProxyAgent + // (dev-environment-core.ts:1044), so it must follow vip-next's proxy policy + // and not http.DefaultTransport's. See internal/httpproxy. + c := httpproxy.ClientWithTimeout(5 * time.Second) + resp, err := c.Get(wordpressVersionsURL) + if err != nil { + return choices + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return choices + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return choices + } + // trunk is already first; skip it (and any dup) from the manifest tags. + seen := map[string]bool{"trunk": true} + for _, tag := range parseWordPressTags(body) { + if !seen[tag] { + seen[tag] = true + choices = append(choices, tag) + } + } + return choices +} + +// parseWordPressTags extracts the unique, non-empty `tag` values from the +// versions.json manifest, preserving the manifest's (newest-first) order. +func parseWordPressTags(body []byte) []string { + var entries []struct { + Tag string `json:"tag"` + } + if err := json.Unmarshal(body, &entries); err != nil { + return nil + } + seen := map[string]bool{} + var tags []string + for _, e := range entries { + if e.Tag == "" || seen[e.Tag] { + continue + } + seen[e.Tag] = true + tags = append(tags, e.Tag) + } + return tags +} + +func runDevEnvCreate(cmd *cobra.Command, _ []string) error { + // When invoked as `@app.env dev-env create`, seed the wizard from the app's + // environment (Node parity: getApplicationInformation + getOptionsFromAppInfo). + // Best-effort: a nil result (no alias, or a failed fetch) falls back to the + // generic defaults. + defaults := fetchAppCreateDefaults(cmd) + cfg, err := resolveCreateConfig(cmd, defaults) + if err != nil { + return err + } + ctx, finish := openDevEnvLog(cmd, cfg.Slug, true) + defer finish() + if err := devenv.Create(ctx, cfg); err != nil { + return err + } + out := cmd.OutOrStdout() + // Print the env info table (Node parity: printEnvironmentInfo after create). + if info, err := devenv.Info(ctx, cfg.Slug); err == nil { + fmt.Fprint(out, info) + } + if cfg.Start { + fmt.Fprintf(out, "\n✓ Environment %q created and started.\n", cfg.Slug) + } else { + fmt.Fprintf(out, "\n✓ Environment %q created.\n\nTo start the environment run:\n\n %s\n", cfg.Slug, environmentStartCommand(cfg.Slug)) + } + return nil +} + +// environmentStartCommand ports getEnvironmentStartCommand +// (dev-environment-cli.ts:196-206): omit --slug when the slug came from the +// discovered configuration file, because `vip dev-env start` will resolve to +// the same environment there. Printing --slug for a configuration-file slug is +// not wrong, but printing it for a slug the file does NOT name would send the +// user at a different environment — and since `create` no longer starts by +// default, this line is how they start it. +func environmentStartCommand(slug string) string { + if slug == "" { + return "vip dev-env start" + } + if cfg, err := devenv.LoadConfigFile(); err == nil && cfg != nil && cfg.Slug == slug { + return "vip dev-env start" + } + return "vip dev-env start --slug " + slug +} + +// resolveCreateConfig builds the CreateConfig from flags, running the Node-style +// setup wizard for any field not passed as a flag when the session is +// interactive, and otherwise applying defaults. A field passed as a flag always +// wins (its prompt is skipped); non-interactive runs never prompt, so scripted +// `create` stays headless. +func resolveCreateConfig(cmd *cobra.Command, defaults *createDefaults) (devenv.CreateConfig, error) { + f := cmd.Flags() + interactive := appctx.IsInteractive(cmd) + if interactive { + fmt.Fprint(cmd.OutOrStdout(), devEnvWizardIntro) + } + + var d createDefaults + if defaults != nil { + d = *defaults + } + + var cfg devenv.CreateConfig + + // slug: flag → .wpvip/vip-dev-env.yml → prompt (default vip-local) → + // default. Node runs create's slug through the same getEnvironmentName as + // every other dev-env command (vip-dev-env-create.js:103), so a configured + // repo creates the CONFIGURED environment — otherwise create and + // start/destroy would target different environments in the same repo + // (register item 2.21). processSlug lowercases the FLAG value + // (dev-environment-cli.ts:979) so the on-disk directory and the compose + // project name agree with Node's; the configuration file's slug is used + // verbatim, as Node does. + slug, _ := f.GetString("slug") + if slug != "" { + cfg.Slug = nodeflags.ProcessSlug(slug) + } else { + fromFile, err := configFileSlug(cmd) + if err != nil { + return cfg, err + } + switch { + case fromFile != "": + cfg.Slug = fromFile + default: + slug = "vip-local" + if interactive { + v, err := appctx.Input(cmd, "Environment slug", slug) + if err != nil { + return cfg, err + } + slug = v + } + cfg.Slug = nodeflags.ProcessSlug(slug) + } + } + + // title (default from app env name, else "VIP Dev"). + titleDefault := "VIP Dev" + if d.Title != "" { + titleDefault = d.Title + } + if f.Changed("title") { + cfg.Title, _ = f.GetString("title") + } else if interactive { + v, err := appctx.Input(cmd, "WordPress site title", titleDefault) + if err != nil { + return cfg, err + } + cfg.Title = v + } else { + cfg.Title = titleDefault + } + + // multisite (default from the app env, else single site). The prompt text + // echoes the app's multisite status (Node: "Multisite ( IS/is NOT + // multisite)"). + msDefaultChoice := "single site" + if d.Multisite { + msDefaultChoice = "subdomain" + } + msPrompt := "Multisite" + if d.Title != "" { + status := "is NOT" + if d.Multisite { + status = "IS" + } + msPrompt = fmt.Sprintf("Multisite (%s %s multisite)", d.Title, status) + } + if f.Changed("multisite") { + ms, _ := f.GetString("multisite") + cfg.MultisiteMode = normalizeMultisite(ms) + } else if interactive { + choice, err := selectWithDefault(cmd, msPrompt, []string{"single site", "subdomain", "subdirectory"}, msDefaultChoice) + if err != nil { + return cfg, err + } + cfg.MultisiteMode = normalizeMultisite(choice) + } else { + cfg.MultisiteMode = normalizeMultisite(msDefaultChoice) + } + + // php (default from the app env, else recommended; empty => NewView resolves + // to php-fpm:8.2). The wizard lists the versions with Node's + // recommended/experimental labels, pre-selecting the app's version. + if f.Changed("php") { + cfg.PHP, _ = f.GetString("php") + if err := validatePHPVersion(cfg.PHP); err != nil { + return cfg, err + } + } else if interactive { + sel, err := selectWithDefault(cmd, "PHP version", phpLabels(), phpLabelForVersion(d.PHP)) + if err != nil { + return cfg, err + } + cfg.PHP = phpVersionForLabel(sel) + } else { + cfg.PHP = d.PHP + } + + // wordpress (default from the app env, else trunk; empty => NewView resolves + // to trunk). The wizard lists the available versions (fetched from the + // container-images repo, with trunk first), pre-selecting the app's version. + if f.Changed("wordpress") { + cfg.WordPress, _ = f.GetString("wordpress") + } else if interactive { + v, err := selectWithDefault(cmd, "WordPress version", wordpressVersionChoices(), d.WordPress) + if err != nil { + return cfg, err + } + cfg.WordPress = v + } else { + cfg.WordPress = d.WordPress + } + + // app-code local path (blank => demo/image). + if f.Changed("app-code") { + cfg.AppCodeDir = devEnvComponentDir(cmd, "app-code") + } else if interactive { + v, err := appctx.Input(cmd, "Path to local application code (blank for demo)", "") + if err != nil { + return cfg, err + } + cfg.AppCodeDir = v + } + + // mu-plugins local path (blank => image). + if f.Changed("mu-plugins") { + cfg.MuPluginsDir = devEnvComponentDir(cmd, "mu-plugins") + } else if interactive { + v, err := appctx.Input(cmd, "Path to local mu-plugins (blank for image)", "") + if err != nil { + return cfg, err + } + cfg.MuPluginsDir = v + } + + // Boolean service toggles (default off). + var berr error + if cfg.Elasticsearch, berr = resolveCreateBool(cmd, "elasticsearch", "Enable Elasticsearch (needed by Enterprise Search)?"); berr != nil { + return cfg, berr + } + if cfg.PHPMyAdmin, berr = resolveCreateBool(cmd, "phpmyadmin", "Enable phpMyAdmin?"); berr != nil { + return cfg, berr + } + if cfg.Xdebug, berr = resolveCreateBool(cmd, "xdebug", "Enable Xdebug?"); berr != nil { + return cfg, berr + } + if cfg.Mailpit, berr = resolveCreateBool(cmd, "mailpit", "Enable Mailpit?"); berr != nil { + return cfg, berr + } + if cfg.Photon, berr = resolveCreateBool(cmd, "photon", "Enable Photon?"); berr != nil { + return cfg, berr + } + if cfg.Cron, berr = resolveCreateBool(cmd, "cron", "Enable cron?"); berr != nil { + return cfg, berr + } + + // Non-prompted passthrough flags. media-redirect-domain defaults to the app + // env's primary domain (Node getOptionsFromAppInfo.mediaRedirectDomain). + cfg.XdebugConfig, _ = devEnvXdebugConfig(cmd) + if f.Changed("media-redirect-domain") { + v, err := devEnvMediaRedirectDomain(cmd) + if err != nil { + return cfg, err + } + cfg.MediaDomain = v + } else { + cfg.MediaDomain = d.MediaRedirectDomain + } + cfg.Domain, _ = f.GetString("domain") + cfg.Start, _ = f.GetBool("start") + return cfg, nil +} + +// resolveCreateBool returns the coerced y/n flag when set, else prompts +// (interactive), else false. Never prompts in non-interactive mode (keeps +// scripted create headless). +func resolveCreateBool(cmd *cobra.Command, name, prompt string) (bool, error) { + if cmd.Flags().Changed(name) { + return devEnvServiceFlag(cmd, name), nil + } + if appctx.IsInteractive(cmd) { + return appctx.Confirm(cmd, prompt, false) + } + return false, nil +} + +// normalizeMultisite maps the Node --multisite values to +// CreateConfig.MultisiteMode via processStringOrBooleanOption +// (dev-environment-cli.ts:963): a truthy word means subdomain, a falsy word +// means single site, and any other string is the mode name itself. +func normalizeMultisite(s string) string { + v := nodeflags.ProcessStringOrBooleanOption(s) + if v.Kind == nodeflags.KindBool { + if v.Bool { + return "subdomain" + } + return "" + } + switch v.String { + case "subdomain": + return "subdomain" + case "subdirectory": + return "subdirectory" + default: + return "" + } +} + +func devEnvStartCmd() *cobra.Command { + var skipRebuild, skipWPVersions, vscode bool + var editor string + c := &cobra.Command{Use: "start", Short: "Start a local environment", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + _ = skipWPVersions // accepted for Node parity; the Go port has no WP-version prompt to skip. + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + ctx, finish := openDevEnvLog(cmd, slug, false) + defer finish() + + // One-time Lando adoption: detect a pre-existing Lando footprint for + // this slug and, on confirmation, hand it to the Go engine before start. + startOpts := devenv.StartOptions{SkipRebuild: skipRebuild} + if plan, perr := devenv.PlanLandoMigration(ctx, slug); perr != nil { + // Best-effort: a detection failure must never block a normal start. + fmt.Fprintf(cmd.ErrOrStderr(), "note: could not check for a pre-existing Lando environment: %v\n", perr) + } else if plan.Detected { + if skip, _ := cmd.Flags().GetBool("skip-confirmation"); !skip { + msg := fmt.Sprintf("Found an existing Lando environment %q. vip-next will take it over — reusing its database and removing the old Lando containers (your data volume is kept). This process is irreversible. Continue?", slug) + confirmed, cerr := appctx.Confirm(cmd, msg, false) + if cerr == appctx.ErrNonInteractive || (!confirmed && cerr == nil) { + fmt.Fprintln(cmd.OutOrStdout(), "Command cancelled") + return nil + } + if cerr != nil { + return cerr + } + } + startOpts.Lando = &plan + } + + if err := devenv.Start(ctx, slug, startOpts); err != nil { + return err + } + // Node keeps --vscode as a deprecated spelling of + // --editor=vscode (src/bin/vip-dev-env-start.js:70,86). + if editor == "" && vscode { + editor = "vscode" + } + if editor != "" { + ws, err := devenv.GenerateEditorWorkspace(slug, editor) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Editor workspace written: %s\n", ws) + } + // Print the env info table (Node parity: printEnvironmentInfo after start). + if info, err := devenv.Info(ctx, slug); err == nil { + fmt.Fprint(cmd.OutOrStdout(), info) + } + return nil + }} + addSlugFlag(c) + appctx.WithSkipConfirmationFlag(c) // registers --skip-confirmation (idempotent) + c.Flags().BoolVar(&skipRebuild, "skip-rebuild", false, "Only start services that are not already in a running state.") + c.Flags().BoolVarP(&skipWPVersions, "skip-wp-versions-check", "w", false, "Skip the WordPress version check (accepted; the Go port has no such prompt).") + // Node gives --vscode no short: 'v' is reserved for --version + // (RESERVED_AUTO_SHORT_ALIASES, src/lib/cli/command.js:42). + c.Flags().BoolVar(&vscode, "vscode", false, "Generate a Visual Studio Code Workspace file (deprecated, use --editor=vscode instead).") + c.Flags().StringVarP(&editor, "editor", "e", "", "Generate an editor workspace file (vscode, cursor, or windsurf).") + return c +} + +func devEnvStopCmd() *cobra.Command { + var all bool + c := &cobra.Command{Use: "stop", Short: "Stop a local environment", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if all { + return devenv.StopAll(cmd.Context()) + } + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + return devenv.Stop(cmd.Context(), slug) + }} + addSlugFlag(c) + c.Flags().BoolVarP(&all, "all", "a", false, "Stop all local environments.") + return c +} + +func devEnvDestroyCmd() *cobra.Command { + var yes, soft bool + c := &cobra.Command{Use: "destroy", Short: "Remove a local environment", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + if !yes { + msg := fmt.Sprintf("Destroy environment %q? This deletes its data.", slug) + if soft { + msg = fmt.Sprintf("Destroy environment %q? Its configuration files are kept (--soft).", slug) + } + ok, err := appctx.Confirm(cmd, msg, false) + if err != nil { + return err + } + if !ok { + return nil + } + } + return devenv.Destroy(cmd.Context(), slug, soft) + }} + addSlugFlag(c) + c.Flags().BoolVar(&yes, "yes", false, "Skip the confirmation prompt.") + c.Flags().BoolVar(&soft, "soft", false, "Preserve the environment's configuration files so it can be recreated.") + return c +} + +func devEnvInfoCmd() *cobra.Command { + var all, extended bool + c := &cobra.Command{Use: "info", Short: "Show information about a local environment", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + out := cmd.OutOrStdout() + _ = extended // Node: "Deprecated, not used." (vip-dev-env-info.js:46) + if all { + s, err := devenv.InfoAll(cmd.Context()) + if err != nil { + return err + } + fmt.Fprint(out, s) + return nil + } + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + s, err := devenv.Info(cmd.Context(), slug) + if err != nil { + return err + } + fmt.Fprint(out, s) + return nil + }} + addSlugFlag(c) + c.Flags().BoolVarP(&all, "all", "a", false, "Show information about all local environments.") + c.Flags().BoolVarP(&extended, "extended", "e", false, "Deprecated, not used.") + return c +} + +func devEnvListCmd() *cobra.Command { + return &cobra.Command{Use: "list", Short: "List local environments", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + envs, err := devenv.List(cmd.Context()) + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(envs) == 0 { + fmt.Fprintln(out, "No local environments found.") + return nil + } + fmt.Fprintf(out, "%-30s %s\n", "SLUG", "STATUS") + for _, e := range envs { + status := "stopped" + if e.Running { + status = "running" + } + fmt.Fprintf(out, "%-30s %s\n", e.Slug, status) + } + return nil + }} +} + +func devEnvPurgeCmd() *cobra.Command { + var yes, force, soft bool + c := &cobra.Command{Use: "purge", Short: "Remove all local environments and shared services", SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if !yes && !force { + ok, err := appctx.Confirm(cmd, "Purge ALL local environments and shared services? This deletes all data.", false) + if err != nil { + return err + } + if !ok { + return nil + } + } + return devenv.Purge(cmd.Context(), soft) + }} + c.Flags().BoolVar(&yes, "yes", false, "Skip the confirmation prompt.") + c.Flags().BoolVarP(&force, "force", "f", false, "Skip the confirmation prompt (alias of --yes).") + c.Flags().BoolVarP(&soft, "soft", "s", false, "Preserve every environment's configuration files.") + return c +} diff --git a/cmd/vip-next/commands/devenv_logs.go b/cmd/vip-next/commands/devenv_logs.go new file mode 100644 index 000000000..739bceeb9 --- /dev/null +++ b/cmd/vip-next/commands/devenv_logs.go @@ -0,0 +1,29 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/devenv" +) + +func newDevEnvLogsCmd() *cobra.Command { + var follow bool + var service string + c := &cobra.Command{ + Use: "logs", + Short: "Show logs for a local environment", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + return devenv.Logs(cmd.Context(), slug, devenv.LogOptions{Follow: follow, Service: service}) + }, + } + addSlugFlag(c) + c.Flags().BoolVarP(&follow, "follow", "f", false, "Continually output logs as they are generated.") + c.Flags().StringVar(&service, "service", "", "Restrict to a single service.") + return c +} diff --git a/cmd/vip-next/commands/devenv_resolve.go b/cmd/vip-next/commands/devenv_resolve.go new file mode 100644 index 000000000..398f5e506 --- /dev/null +++ b/cmd/vip-next/commands/devenv_resolve.go @@ -0,0 +1,106 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/nodeflags" +) + +// ResolveSlug determines which EXISTING environment a command targets, matching +// the Node getEnvironmentName resolution order (dev-environment-cli.ts:146-192): +// 1. --slug, if set (lowercased by Node's processSlug — +// dev-environment-cli.ts:979 — which every dev-env bin registers as the +// option's parse function). +// 2. An @app.env alias: REJECTED with Node's message. allowAppEnv is set by +// `dev-env create` alone (vip-dev-env-create.js:95); every other dev-env +// command refuses it rather than guessing a local environment — this is a +// guard on destructive commands, so it must fire before anything else. +// 3. The slug from a discovered .wpvip/vip-dev-env.yml, announced with Node's +// "Using environment X from Y" line. +// 4. The sole environment, when exactly one exists. +// 5. Interactive: prompt to select from the existing environments. +// 6. Otherwise: a clear "specify --slug" error (wrapping ErrNonInteractive). +// +// Steps 5 and 6 are vip-next's replacement for Node's "More than one +// environment found" error / DEFAULT_SLUG fallback. +func ResolveSlug(cmd *cobra.Command) (string, error) { + if s, _ := cmd.Flags().GetString("slug"); s != "" { + return nodeflags.ProcessSlug(s), nil + } + if err := rejectAppEnvAlias(cmd); err != nil { + return "", err + } + return ResolveLocalSlug(cmd) +} + +// ResolveLocalSlug is ResolveSlug without the @app.env guard, for the one +// dev-env leaf where an alias is meaningful: `dev-env sync sql` uses @app.env +// to name the PLATFORM environment it exports from, and resolves the LOCAL +// target separately. Node models this by destructuring app/env out of the +// options before calling getEnvironmentName (vip-dev-env-sync-sql.js:98). +func ResolveLocalSlug(cmd *cobra.Command) (string, error) { + if s, _ := cmd.Flags().GetString("slug"); s != "" { + return nodeflags.ProcessSlug(s), nil + } + slug, err := configFileSlug(cmd) + if err != nil || slug != "" { + return slug, err + } + names := instancedata.AllNames() + switch len(names) { + case 0: + return "", errors.New("no dev environments found; create one with `vip dev-env create`") + case 1: + return names[0], nil + } + if appctx.IsInteractive(cmd) { + return appctx.Select(cmd, "Which environment?", names) + } + return "", fmt.Errorf("multiple environments found; specify --slug: %w", appctx.ErrNonInteractive) +} + +// rejectAppEnvAlias ports getEnvironmentName's @app.env guard. `--app`/`--env` +// are root persistent flags that the alias PersistentPreRunE fills in from an +// `@app.env` token, so this covers both spellings — as it does in Node, where +// command.js:570 populates options.app from the parsed alias. +func rejectAppEnvAlias(cmd *cobra.Command) error { + app, _ := cmd.Flags().GetString("app") + if app == "" { + return nil + } + name := app + if env, _ := cmd.Flags().GetString("env"); env != "" { + name += "-" + env + } + return fmt.Errorf("This command does not support @app.env notation. Use '--slug=%s' to target the local environment.", name) +} + +// configFileSlug returns the slug from a discovered dev-env configuration file +// (walking up from the working directory), or "" when there is none. A file +// that exists but cannot be parsed is a hard error: Node exits there, and +// falling through would let `destroy` target a DIFFERENT environment than the +// repo is configured for. +// +// Node's slug is used verbatim here — unlike --slug it is not passed through +// processSlug, so it is not lowercased. +func configFileSlug(cmd *cobra.Command) (string, error) { + cfg, err := devenv.LoadConfigFile() + if err != nil { + return "", err + } + if cfg == nil || cfg.Slug == "" { + return "", nil + } + // Node suppresses the announcement only where the caller passes its + // `quiet` option — `dev-env import sql --quiet` (vip-dev-env-import-sql.js:65). + if quiet, _ := cmd.Flags().GetBool("quiet"); !quiet { + fmt.Fprintf(cmd.OutOrStdout(), "Using environment %s from %s\n\n", cfg.Slug, cfg.Path) + } + return cfg.Slug, nil +} diff --git a/cmd/vip-next/commands/devenv_resolve_test.go b/cmd/vip-next/commands/devenv_resolve_test.go new file mode 100644 index 000000000..979949165 --- /dev/null +++ b/cmd/vip-next/commands/devenv_resolve_test.go @@ -0,0 +1,221 @@ +package commands + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// newSlugCmd builds a cobra command with a --slug flag + --non-interactive, +// mirroring what the real dev-env leaves register. +func newSlugCmd(slug string) *cobra.Command { + c := &cobra.Command{Use: "x"} + c.Flags().String("slug", "", "") + c.Flags().Bool("non-interactive", false, "") + // --app/--env live on the root command in production; the alias + // PersistentPreRunE sets them from an @app.env token. + c.Flags().String("app", "", "") + c.Flags().String("env", "", "") + if slug != "" { + _ = c.Flags().Set("slug", slug) + } + return c +} + +func mkEnv(t *testing.T, base, slug string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(base, "vip", "dev-environment", slug), 0o755); err != nil { + t.Fatal(err) + } +} + +func TestResolveSlugFlagWins(t *testing.T) { + got, err := ResolveSlug(newSlugCmd("chosen")) + if err != nil || got != "chosen" { + t.Fatalf("ResolveSlug = %q, %v; want chosen", got, err) + } +} + +func TestResolveSlugSoleEnv(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "only-one") + got, err := ResolveSlug(newSlugCmd("")) + if err != nil || got != "only-one" { + t.Fatalf("ResolveSlug = %q, %v; want only-one", got, err) + } +} + +func TestResolveSlugNoneIsError(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + _, err := ResolveSlug(newSlugCmd("")) + if err == nil { + t.Fatal("expected error when no environments exist") + } +} + +// writeDevEnvConfig drops a .wpvip/vip-dev-env.yml in dir. +func writeDevEnvConfig(t *testing.T, dir, slug string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(dir, ".wpvip"), 0o755); err != nil { + t.Fatal(err) + } + body := "configuration-version: 1\nslug: " + slug + "\n" + if err := os.WriteFile(filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// Register 2.21. In a configured repo every dev-env command must target the +// configured environment, NOT whichever environment happens to be on disk. +// Before this fix `dev-env destroy` in a repo configured for "configured-site" +// destroyed the unrelated environment "some-other-env". +func TestResolveSlugUsesConfigurationFile(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "some-other-env") + repo := t.TempDir() + writeDevEnvConfig(t, repo, "configured-site") + t.Chdir(repo) + + got, err := ResolveSlug(newSlugCmd("")) + if err != nil { + t.Fatal(err) + } + if got != "configured-site" { + t.Fatalf("ResolveSlug = %q, want configured-site (from .wpvip/vip-dev-env.yml)", got) + } +} + +// Node prints `Using environment <slug> from <path>` when the configuration +// file decides the target (dev-environment-cli.ts:170-176). +func TestResolveSlugPrintsUsingEnvironment(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + repo := t.TempDir() + writeDevEnvConfig(t, repo, "configured-site") + t.Chdir(repo) + + c := newSlugCmd("") + var out bytes.Buffer + c.SetOut(&out) + if _, err := ResolveSlug(c); err != nil { + t.Fatal(err) + } + want := "Using environment configured-site from " + filepath.Join(repo, ".wpvip", "vip-dev-env.yml") + if !strings.Contains(out.String(), want) { + t.Errorf("output = %q, want it to contain %q", out.String(), want) + } +} + +// --slug beats the configuration file (Node checks options.slug first). +func TestResolveSlugFlagBeatsConfigurationFile(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + repo := t.TempDir() + writeDevEnvConfig(t, repo, "configured-site") + t.Chdir(repo) + + got, err := ResolveSlug(newSlugCmd("explicit")) + if err != nil { + t.Fatal(err) + } + if got != "explicit" { + t.Fatalf("ResolveSlug = %q, want explicit", got) + } +} + +// A broken configuration file must be fatal, not silently ignored — otherwise +// `destroy` falls through to some other environment. +func TestResolveSlugBrokenConfigurationFileIsFatal(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "some-other-env") + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".wpvip"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".wpvip", "vip-dev-env.yml"), []byte("slug: x\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + + if _, err := ResolveSlug(newSlugCmd("")); err == nil { + t.Fatal("a malformed configuration file must fail, not fall through to another environment") + } +} + +// Node rejects @app.env on every dev-env command except create +// (getEnvironmentName: allowAppEnv is only set by vip-dev-env-create.js:95). +// The message tells the user the --slug form to use instead. +func TestResolveSlugRejectsAppEnvAlias(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "only-one") + c := newSlugCmd("") + _ = c.Flags().Set("app", "example-app") + _ = c.Flags().Set("env", "develop") + + _, err := ResolveSlug(c) + if err == nil { + t.Fatal("@app.env must be rejected on a dev-env command") + } + const want = "This command does not support @app.env notation. Use '--slug=example-app-develop' to target the local environment." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +// Node builds the suggested slug from app + "-" + env, and omits the suffix +// when there is no env part. +func TestResolveSlugRejectsAppAliasWithoutEnv(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "only-one") + c := newSlugCmd("") + _ = c.Flags().Set("app", "example-app") + + _, err := ResolveSlug(c) + if err == nil || !strings.Contains(err.Error(), "--slug=example-app'") { + t.Fatalf("error = %v, want the bare app name in the suggested --slug", err) + } +} + +// `dev-env sync sql` is the one dev-env leaf where @app.env is meaningful: it +// names the PLATFORM environment to export from, while the local target comes +// from --slug/the configuration file. Node strips app/env before calling +// getEnvironmentName (`const { app, env, ... } = opt` in +// vip-dev-env-sync-sql.js), so the guard must not fire there. +func TestResolveLocalSlugIgnoresAppEnvForSync(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + mkEnv(t, base, "only-one") + c := newSlugCmd("") + _ = c.Flags().Set("app", "example-app") + _ = c.Flags().Set("env", "develop") + + got, err := ResolveLocalSlug(c) + if err != nil { + t.Fatalf("sync sql must accept @app.env: %v", err) + } + if got != "only-one" { + t.Fatalf("ResolveLocalSlug = %q, want only-one", got) + } +} + +func TestResolveSlugAmbiguousNonInteractive(t *testing.T) { + base := t.TempDir() + t.Setenv("XDG_DATA_HOME", base) + t.Setenv("VIP_NON_INTERACTIVE", "1") + mkEnv(t, base, "a") + mkEnv(t, base, "b") + _, err := ResolveSlug(newSlugCmd("")) + if !errors.Is(err, appctx.ErrNonInteractive) { + t.Fatalf("want ErrNonInteractive, got %v", err) + } +} diff --git a/cmd/vip-next/commands/devenv_sync.go b/cmd/vip-next/commands/devenv_sync.go new file mode 100644 index 000000000..08b6425b9 --- /dev/null +++ b/cmd/vip-next/commands/devenv_sync.go @@ -0,0 +1,256 @@ +package commands + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/sqlexport" + "github.com/Automattic/vip/internal/tui" +) + +func newDevEnvSyncCmd() *cobra.Command { + sync := &cobra.Command{Use: "sync", Short: "Sync a VIP Platform environment into a local environment"} + sync.AddCommand(newDevEnvSyncSQLCmd()) + return sync +} + +func newDevEnvSyncSQLCmd() *cobra.Command { + c := &cobra.Command{ + Use: "sql", + Short: "Sync the database of a VIP Platform environment to a local environment", + SilenceUsage: true, + SilenceErrors: true, + } + addSlugFlag(c) + c.Flags().StringArrayP("table", "t", nil, "Table to include in a partial sync (repeatable, or comma-separated).") + c.Flags().StringArray("site-id", nil, "Network site id to include in a partial sync (repeatable, or comma-separated).") + c.Flags().StringP("wpcli-command", "w", "", "Custom WP-CLI command that retrieves the data for a partial export.") + c.Flags().StringP("config-file", "c", "", "Local configuration file specifying the data to sync.") + c.Flags().StringArrayP("search-replace", "r", nil, "Map a source URL or domain to a routable local target; repeatable (source,target).") + c.Flags().BoolP("force", "f", false, "Skip validations (e.g. the running-environment check).") + + addAppEnvFlags(c) + cfg := GetConfig() + return appctx.Build(c, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runDevEnvSyncSQL) +} + +func runDevEnvSyncSQL(cmd *cobra.Command, _ []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + // ResolveLocalSlug, not ResolveSlug: here @app.env names the PLATFORM + // environment to export from, so it must not be rejected as a local target. + slug, err := ResolveLocalSlug(cmd) + if err != nil { + return err + } + out := cmd.OutOrStdout() + + tables, _ := cmd.Flags().GetStringArray("table") + siteIDs, _ := cmd.Flags().GetStringArray("site-id") + wpcliCommand, _ := cmd.Flags().GetString("wpcli-command") + configFile, _ := cmd.Flags().GetString("config-file") + overrides, _ := cmd.Flags().GetStringArray("search-replace") + liveCopy, err := sqlexport.ParseLiveCopyCLIOptions(configFile, tables, siteIDs, wpcliCommand) + if err != nil { + return err + } + + domain := compose.DefaultDomain + if d, derr := instancedata.Read(slug); derr == nil && d.Domain != "" { + domain = d.Domain + } + + appID := ae.App.ID + envID := ae.Env.ID + + // exportTo runs the M7 platform export to a temporary .gz next to dest, then + // gunzips it to dest (the plain SQL file the sync orchestration consumes). + // Mirrors Node generateExport + unzipFile (dev-env-sync-sql.ts:184,418). + exportTo := func(ctx context.Context, dest string) error { + pt := tui.NewProgressTracker(sqlexport.Steps()) + renderer := startImportProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + gzPath := dest + ".gz" + // Ignore the saved-path return: the export lands in a temp file the + // user never sees, and printing "File saved to" here would interleave + // with the still-running progress renderer (the duplicated-line bug). + _, rerr := sqlexport.Run(gql.WithAllowGQLErrors(ctx), pt, sqlexport.Options{ + OutputFile: gzPath, + LiveCopy: liveCopy, + Interval: exportPollInterval(), + AppID: appID, + AppName: ae.App.Name, + EnvUniqueLabel: ae.Env.UniqueLabel, + }, buildExportDeps(cmd, appID, envID, out), out) + renderer.stop(cmd, true) + if rerr != nil { + return rerr + } + + fmt.Fprintf(out, "Extracting the exported file %s...\n", gzPath) + if uerr := gunzipFile(gzPath, dest); uerr != nil { + return fmt.Errorf("Error extracting the SQL export: %s", uerr.Error()) + } + fmt.Fprintf(out, "%s Extracted to %s\n", color.GreenString("✓"), dest) + return nil + } + + baseHost := slug + "." + domain + deps := devenv.SyncDeps{ + ExportTo: exportTo, + FetchSites: func(ctx context.Context) ([]devenv.SyncSite, string) { + return fetchDevEnvSyncSites(ctx, cfg.GQLClient, appID, envID, func(line string) { + fmt.Fprintln(out, line) + }) + }, + ResolveDraft: func(draft devenv.PlanDraft) ([]string, error) { + return resolveSyncMappings(cmd, draft, baseHost) + }, + ImportFile: func(ctx context.Context, slug, file string, pairs []string) error { + // Node runImport: inPlace + skipValidate + quiet (dev-env-sync-sql.ts:333). + // The same DevEnvImportSQLCommand.run() Node uses here, so sync also + // gets the post-import steps (cache flush / reindex / vipgo admin + // user / data cleanup) — without them a synced env locks the user + // out of their own local wp-admin. + return devenv.ImportSQL(ctx, slug, file, devenv.ImportOptions{ + SearchReplace: pairs, + InPlace: true, + SkipValidate: true, + Quiet: true, + // Node's sync search-replaces the export itself and passes no + // searchReplace to the import, so it never reaches the + // irreversible-rewrite prompt. Go routes the pairs through + // ImportSQL, so pre-confirm the way Node's batchMode does — + // the file is a temp export, not anything the user named. + BatchMode: true, + Out: out, + }) + }, + RepairDomains: devenv.RepairBlogDomains, + RefreshHosts: devenv.RefreshManagedHosts, + Log: func(msg string) { + fmt.Fprintln(out, msg) + }, + } + return devenv.SyncSQL(cmd.Context(), devenv.SyncOptions{ + Slug: slug, + Domain: domain, + IsMultisite: ae.Env.IsMultisite, + Overrides: overrides, + }, deps) +} + +type syncMappingInput func(*cobra.Command, string, string) (string, error) + +func suggestedSyncTarget(source, baseHost string, index int) string { + host := source + if slash := strings.IndexByte(host, '/'); slash >= 0 { + host = host[:slash] + } + if colon := strings.LastIndexByte(host, ':'); colon >= 0 { + host = host[:colon] + } + var label strings.Builder + lastHyphen := false + for _, char := range strings.ToLower(host) { + if char >= 'a' && char <= 'z' || char >= '0' && char <= '9' { + label.WriteRune(char) + lastHyphen = false + continue + } + if !lastHyphen { + label.WriteByte('-') + lastHyphen = true + } + } + readable := strings.Trim(label.String(), "-") + if readable == "" { + readable = "site" + } + suffix := fmt.Sprintf("-r%d", index+1) + if limit := 63 - len(suffix); len(readable) > limit { + readable = strings.TrimRight(readable[:limit], "-") + } + return readable + suffix + "." + baseHost +} + +func resolveSyncMappingsCore( + cmd *cobra.Command, + draft devenv.PlanDraft, + baseHost string, + interactive bool, + input syncMappingInput, +) ([]string, error) { + if len(draft.Unresolved) == 0 { + return nil, nil + } + if !interactive { + var message strings.Builder + message.WriteString("Multisite URL mappings remain unresolved; no SQL was imported. Re-run with these recovery flags (edit targets if needed):\n") + for index, mapping := range draft.Unresolved { + target := suggestedSyncTarget(mapping.Source, baseHost, index) + fmt.Fprintf(&message, "- %s\n -r \"%s,%s\"\n", mapping.Source, mapping.Source, target) + } + return nil, errors.New(strings.TrimRight(message.String(), "\n")) + } + if input == nil { + return nil, errors.New("interactive sync mapping input is not configured") + } + pairs := make([]string, 0, len(draft.Unresolved)) + for index, mapping := range draft.Unresolved { + fallback := suggestedSyncTarget(mapping.Source, baseHost, index) + target, err := input(cmd, fmt.Sprintf("Local target for %s", mapping.Source), fallback) + if err != nil || strings.TrimSpace(target) == "" { + return nil, devenv.ErrSyncCancelled + } + pairs = append(pairs, mapping.Source+","+strings.TrimSpace(target)) + } + return pairs, nil +} + +func resolveSyncMappings(cmd *cobra.Command, draft devenv.PlanDraft, baseHost string) ([]string, error) { + return resolveSyncMappingsCore(cmd, draft, baseHost, appctx.IsInteractive(cmd), appctx.Input) +} + +// gunzipFile decompresses a gzip file at src into dest. +func gunzipFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + gz, err := gzip.NewReader(in) + if err != nil { + return err + } + defer gz.Close() + outFile, err := os.Create(dest) + if err != nil { + return err + } + defer outFile.Close() + if _, err := io.Copy(outFile, gz); err != nil { // #nosec G110 -- trusted platform export + return err + } + return nil +} diff --git a/cmd/vip-next/commands/devenv_sync_sites.go b/cmd/vip-next/commands/devenv_sync_sites.go new file mode 100644 index 000000000..3ae80a28a --- /dev/null +++ b/cmd/vip-next/commands/devenv_sync_sites.go @@ -0,0 +1,116 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/gql" +) + +const devEnvSyncSitesPageSize int64 = 100 + +// fetchDevEnvSyncSites reads the complete SDS catalog. The returned issue is a +// stable, sanitized category: callers may offer explicit recovery mappings, +// while telemetry can classify failures without recording URLs or raw server +// errors. A non-empty issue always returns an empty site slice so a partial or +// malformed catalog can never drive automatic rewrites. +func fetchDevEnvSyncSites( + ctx context.Context, + client graphql.Client, + appID, envID int64, + log func(string), +) ([]devenv.SyncSite, string) { + if client == nil { + return nil, "transport" + } + + var after *string + seenCursors := map[string]bool{} + byBlogID := map[int64]devenv.SyncSite{} + var sites []devenv.SyncSite + var expectedTotal int64 = -1 + var rawCount int64 + + for { + resp, err := gql.DevEnvSyncSites( + gql.WithAllowGQLErrors(ctx), + client, + appID, + envID, + after, + devEnvSyncSitesPageSize, + ) + if err != nil { + return nil, "transport" + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || + resp.App.Environments[0] == nil || resp.App.Environments[0].WpSitesSDS == nil { + return nil, "missing_payload" + } + + page := resp.App.Environments[0].WpSitesSDS + if page.Total == nil || *page.Total < 0 { + return nil, "missing_payload" + } + if expectedTotal < 0 { + expectedTotal = *page.Total + } else if expectedTotal != *page.Total { + return nil, "total_mismatch" + } + + for _, node := range page.Nodes { + rawCount++ + if node == nil || node.BlogId == nil || *node.BlogId <= 0 { + return nil, "invalid_nodes" + } + homeURL := "" + if node.HomeUrl != nil { + homeURL = strings.TrimSpace(*node.HomeUrl) + } + siteURL := "" + if node.SiteUrl != nil { + siteURL = strings.TrimSpace(*node.SiteUrl) + } + if homeURL == "" && siteURL == "" { + return nil, "invalid_nodes" + } + site := devenv.SyncSite{BlogID: *node.BlogId, HomeURL: homeURL, SiteURL: siteURL} + if previous, exists := byBlogID[site.BlogID]; exists { + if previous != site { + return nil, "invalid_nodes" + } + continue + } + byBlogID[site.BlogID] = site + sites = append(sites, site) + } + + if log != nil { + log(fmt.Sprintf("Fetched %d of %d sites...", rawCount, expectedTotal)) + } + next := "" + if page.NextCursor != nil { + next = strings.TrimSpace(*page.NextCursor) + } + if next == "" { + break + } + if seenCursors[next] { + return nil, "cursor_loop" + } + seenCursors[next] = true + after = &next + } + + if rawCount != expectedTotal { + return nil, "total_mismatch" + } + if rawCount == 0 || len(sites) == 0 { + return nil, "empty_catalog" + } + return sites, "" +} diff --git a/cmd/vip-next/commands/devenv_sync_sites_test.go b/cmd/vip-next/commands/devenv_sync_sites_test.go new file mode 100644 index 000000000..1d108a34f --- /dev/null +++ b/cmd/vip-next/commands/devenv_sync_sites_test.go @@ -0,0 +1,151 @@ +package commands + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +type syncSitesRequest struct { + Variables struct { + After *string `json:"after"` + First int64 `json:"first"` + } `json:"variables"` +} + +func TestFetchDevEnvSyncSitesPaginatesStrictly(t *testing.T) { + var afters []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request syncSitesRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode request: %v", err) + } + if request.Variables.First != 100 { + t.Errorf("first = %d, want 100", request.Variables.First) + } + after := "<nil>" + if request.Variables.After != nil { + after = *request.Variables.After + } + afters = append(afters, after) + w.Header().Set("Content-Type", "application/json") + if request.Variables.After == nil { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":3,"nextCursor":"page-2","nodes":[{"blogId":1,"homeUrl":"https://primary.example.com","siteUrl":"https://primary.example.com/wp"},{"blogId":2,"homeUrl":"https://two.primary.example.com","siteUrl":"https://two.primary.example.com/wp"}]}}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":3,"nextCursor":null,"nodes":[{"blogId":3,"homeUrl":"https://three.primary.example.com","siteUrl":"https://three.primary.example.com/wp"}]}}]}}}`)) + })) + defer server.Close() + + var progress []string + sites, issue := fetchDevEnvSyncSites(t.Context(), graphql.NewClient(server.URL, server.Client()), 42, 7, func(line string) { + progress = append(progress, line) + }) + if issue != "" { + t.Fatalf("issue = %q", issue) + } + if got, want := afters, []string{"<nil>", "page-2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("after cursors = %#v, want %#v", got, want) + } + if len(sites) != 3 || sites[0].BlogID != 1 || sites[2].BlogID != 3 { + t.Fatalf("sites = %#v", sites) + } + if got, want := progress, []string{"Fetched 2 of 3 sites...", "Fetched 3 of 3 sites..."}; !reflect.DeepEqual(got, want) { + t.Fatalf("progress = %#v, want %#v", got, want) + } +} + +func TestFetchDevEnvSyncSitesRejectsUnsafeCatalogs(t *testing.T) { + tests := []struct { + name string + responses []string + status int + wantIssue string + }{ + {name: "transport", status: http.StatusInternalServerError, wantIssue: "transport"}, + {name: "missing payload", responses: []string{`{"data":{"app":null}}`}, wantIssue: "missing_payload"}, + {name: "empty catalog", responses: []string{`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":0,"nextCursor":null,"nodes":[]}}]}}}`}, wantIssue: "empty_catalog"}, + {name: "nil node", responses: []string{`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":1,"nextCursor":null,"nodes":[null]}}]}}}`}, wantIssue: "invalid_nodes"}, + {name: "total mismatch", responses: []string{`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":2,"nextCursor":null,"nodes":[{"blogId":1,"homeUrl":"https://primary.example.com"}]}}]}}}`}, wantIssue: "total_mismatch"}, + { + name: "cursor loop", + responses: []string{ + `{"data":{"app":{"environments":[{"wpSitesSDS":{"total":2,"nextCursor":"same","nodes":[{"blogId":1,"homeUrl":"https://primary.example.com"}]}}]}}}`, + `{"data":{"app":{"environments":[{"wpSitesSDS":{"total":2,"nextCursor":"same","nodes":[{"blogId":2,"homeUrl":"https://two.primary.example.com"}]}}]}}}`, + }, + wantIssue: "cursor_loop", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tt.status != 0 { + http.Error(w, "local test failure", tt.status) + return + } + w.Header().Set("Content-Type", "application/json") + index := calls + if index >= len(tt.responses) { + index = len(tt.responses) - 1 + } + calls++ + _, _ = fmt.Fprint(w, tt.responses[index]) + })) + defer server.Close() + + sites, issue := fetchDevEnvSyncSites(t.Context(), graphql.NewClient(server.URL, server.Client()), 42, 7, nil) + if issue != tt.wantIssue { + t.Fatalf("issue = %q, want %q; sites=%#v", issue, tt.wantIssue, sites) + } + if len(sites) != 0 { + t.Fatalf("unsafe catalog returned trusted sites: %#v", sites) + } + }) + } +} + +func TestFetchDevEnvSyncSitesDeduplicatesIdenticalNodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":2,"nextCursor":null,"nodes":[{"blogId":1,"homeUrl":"https://primary.example.com","siteUrl":"https://primary.example.com/wp"},{"blogId":1,"homeUrl":"https://primary.example.com","siteUrl":"https://primary.example.com/wp"}]}}]}}}`)) + })) + defer server.Close() + + sites, issue := fetchDevEnvSyncSites(t.Context(), graphql.NewClient(server.URL, server.Client()), 42, 7, nil) + if issue != "" || len(sites) != 1 { + t.Fatalf("sites=%#v issue=%q, want one deduplicated site", sites, issue) + } +} + +func TestFetchDevEnvSyncSitesRejectsConflictingDuplicateIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"wpSitesSDS":{"total":2,"nextCursor":null,"nodes":[{"blogId":1,"homeUrl":"https://primary.example.com"},{"blogId":1,"homeUrl":"https://other.example.com"}]}}]}}}`)) + })) + defer server.Close() + + sites, issue := fetchDevEnvSyncSites(t.Context(), graphql.NewClient(server.URL, server.Client()), 42, 7, nil) + if issue != "invalid_nodes" || len(sites) != 0 { + t.Fatalf("sites=%#v issue=%q", sites, issue) + } +} + +func TestFetchDevEnvSyncSitesDoesNotLeakRawTransportErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "secret upstream details", http.StatusBadGateway) + })) + defer server.Close() + + _, issue := fetchDevEnvSyncSites(t.Context(), graphql.NewClient(server.URL, server.Client()), 42, 7, nil) + if strings.Contains(issue, "secret") || issue != "transport" { + t.Fatalf("issue = %q", issue) + } +} diff --git a/cmd/vip-next/commands/devenv_sync_test.go b/cmd/vip-next/commands/devenv_sync_test.go new file mode 100644 index 000000000..6d4d6dd9e --- /dev/null +++ b/cmd/vip-next/commands/devenv_sync_test.go @@ -0,0 +1,63 @@ +package commands + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/devenv" +) + +func TestDevEnvSyncSQLSearchReplaceFlagIsRepeatableWithShortForm(t *testing.T) { + cmd := newDevEnvSyncSQLCmd() + if err := cmd.ParseFlags([]string{"-r", "one.example,a.local.vipdev.site", "--search-replace", "two.example,b.local.vipdev.site"}); err != nil { + t.Fatal(err) + } + got, err := cmd.Flags().GetStringArray("search-replace") + if err != nil { + t.Fatal(err) + } + if strings.Join(got, "|") != "one.example,a.local.vipdev.site|two.example,b.local.vipdev.site" { + t.Fatalf("search-replace = %#v", got) + } +} + +func TestResolveSyncMappingsNonInteractivePrintsCopyableFlags(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + draft := devenv.PlanDraft{Unresolved: []devenv.UnresolvedMapping{ + {Source: "missing.example.com", Reason: "transport"}, + {Source: "deep.mapped.example.net/path", Reason: "missing_sds_mapping"}, + }} + _, err := resolveSyncMappingsCore(cmd, draft, "mysite.vipdev.site", false, nil) + if err == nil { + t.Fatal("expected non-interactive unresolved error") + } + for _, want := range []string{"missing.example.com", "deep.mapped.example.net/path", `-r "missing.example.com,`, "mysite.vipdev.site"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } +} + +func TestResolveSyncMappingsInteractiveCollectsPairsAndCancels(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + draft := devenv.PlanDraft{Unresolved: []devenv.UnresolvedMapping{{Source: "missing.example.com"}}} + pairs, err := resolveSyncMappingsCore(cmd, draft, "mysite.vipdev.site", true, + func(_ *cobra.Command, message, fallback string) (string, error) { + if !strings.Contains(message, "missing.example.com") || fallback == "" { + t.Fatalf("message=%q fallback=%q", message, fallback) + } + return "recovered.mysite.vipdev.site", nil + }) + if err != nil || len(pairs) != 1 || pairs[0] != "missing.example.com,recovered.mysite.vipdev.site" { + t.Fatalf("pairs=%#v err=%v", pairs, err) + } + + _, err = resolveSyncMappingsCore(cmd, draft, "mysite.vipdev.site", true, + func(*cobra.Command, string, string) (string, error) { return "", errors.New("interrupt") }) + if !errors.Is(err, devenv.ErrSyncCancelled) { + t.Fatalf("cancel err = %v", err) + } +} diff --git a/cmd/vip-next/commands/devenv_test.go b/cmd/vip-next/commands/devenv_test.go new file mode 100644 index 000000000..7209b7eaf --- /dev/null +++ b/cmd/vip-next/commands/devenv_test.go @@ -0,0 +1,60 @@ +package commands + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestDevEnvStartRegistersSkipConfirmation(t *testing.T) { + c := devEnvStartCmd() + if c.Flag("skip-confirmation") == nil { + t.Fatal("start must register --skip-confirmation for Lando adoption") + } +} + +// leafNames collects the full subcommand path set so we can assert the tree. +func leafNames(c *cobra.Command, prefix string, out map[string]bool) { + name := prefix + c.Name() + if !c.HasSubCommands() { + out[name] = true + return + } + for _, ch := range c.Commands() { + leafNames(ch, name+" ", out) + } +} + +func TestDevEnvTreeHasAll23Leaves(t *testing.T) { + root := DevEnvCmd() + got := map[string]bool{} + leafNames(root, "", got) + want := []string{ + "dev-env create", "dev-env destroy", "dev-env start", "dev-env stop", + "dev-env exec", "dev-env shell", "dev-env info", "dev-env list", + "dev-env logs", "dev-env purge", "dev-env update", + "dev-env sync sql", + "dev-env envvar get", "dev-env envvar get-all", "dev-env envvar list", + "dev-env envvar set", "dev-env envvar delete", + "dev-env import sql", "dev-env import media", + } + for _, w := range want { + if !got[w] { + t.Errorf("missing leaf %q (have %v)", w, got) + } + } +} + +func TestDevEnvCreateParsesFlags(t *testing.T) { + root := DevEnvCmd() + c, _, err := root.Find([]string{"create"}) + if err != nil { + t.Fatal(err) + } + if err := c.ParseFlags([]string{"--slug", "x", "--php", "8.2", "--multisite", "subdirectory", "--start=false"}); err != nil { + t.Fatalf("create flags failed to parse: %v", err) + } + if v, _ := c.Flags().GetString("php"); v != "8.2" { + t.Fatalf("--php = %q, want 8.2", v) + } +} diff --git a/cmd/vip-next/commands/devenv_update.go b/cmd/vip-next/commands/devenv_update.go new file mode 100644 index 000000000..33add7f75 --- /dev/null +++ b/cmd/vip-next/commands/devenv_update.go @@ -0,0 +1,207 @@ +package commands + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv" + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +// devEnvUpdateWizardIntro heads the interactive update wizard. +const devEnvUpdateWizardIntro = "Update wizard — each value defaults to the environment's current setting;\n" + + "press Enter to keep it, or change it. Pass the matching flags (or\n" + + "--non-interactive) to skip the wizard.\n\n" + +func newDevEnvUpdateCmd() *cobra.Command { + c := &cobra.Command{ + Use: "update", + Short: "Update a local environment", + SilenceUsage: true, + SilenceErrors: true, + RunE: runDevEnvUpdate, + } + f := c.Flags() + addXdebugConfigAlias(c) + f.StringP("slug", "s", "", "A unique name for a local environment.") + f.String("php", "", "PHP image/version.") + f.StringP("wordpress", "w", "", "WordPress version tag.") + f.StringP("mu-plugins", "u", "", `Source for VIP MU plugins. Accepts "demo" or a local path.`) + f.StringP("app-code", "a", "", `Source for application code. Accepts "demo" or a local path.`) + addDevEnvServiceFlags(c) + f.StringP("media-redirect-domain", "r", "", `Proxy media from a VIP Platform environment. Accepts a domain, or "n" to disable.`) + return c +} + +func runDevEnvUpdate(cmd *cobra.Command, _ []string) error { + slug, err := ResolveSlug(cmd) + if err != nil { + return err + } + cur, err := instancedata.Read(slug) + if err != nil { + return err + } + c, err := resolveUpdateConfig(cmd, cur) + if err != nil { + return err + } + if err := devenv.Update(cmd.Context(), slug, c); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Environment %q updated. Restart it for changes to take effect:\n vip dev-env --slug %s start\n", slug, slug) + return nil +} + +// resolveUpdateConfig builds the UpdateConfig from flags, running the setup +// wizard for any field not passed as a flag when interactive (each prompt +// defaults to the env's CURRENT value — Node's update behavior). A flag always +// wins; non-interactive runs only overlay the passed flags (everything else is +// left unchanged), so scripted update stays headless. Title and multisite are +// intentionally not prompted (Node keeps them on update). +func resolveUpdateConfig(cmd *cobra.Command, cur *instancedata.InstanceData) (devenv.UpdateConfig, error) { + f := cmd.Flags() + interactive := appctx.IsInteractive(cmd) + if interactive { + fmt.Fprint(cmd.OutOrStdout(), devEnvUpdateWizardIntro) + } + + var c devenv.UpdateConfig + + // php — default to the current version's label. + if f.Changed("php") { + v, _ := f.GetString("php") + c.PHP = &v + } else if interactive { + sel, err := selectWithDefault(cmd, "PHP version", phpLabels(), phpLabelForVersion(currentPHPVersion(cur))) + if err != nil { + return c, err + } + v := phpVersionForLabel(sel) + c.PHP = &v + } + + // wordpress — default to the current tag. + if f.Changed("wordpress") { + v, _ := f.GetString("wordpress") + c.WordPress = &v + } else if interactive { + v, err := selectWithDefault(cmd, "WordPress version", wordpressVersionChoices(), cur.WordPress.Tag) + if err != nil { + return c, err + } + c.WordPress = &v + } + + // mu-plugins / app-code — default to the current local path. + if f.Changed("mu-plugins") { + v := devEnvComponentDir(cmd, "mu-plugins") + c.MuPluginsDir = &v + } else if interactive { + v, err := appctx.Input(cmd, "Path to local mu-plugins (blank for image)", cur.MuPlugins.Dir) + if err != nil { + return c, err + } + c.MuPluginsDir = &v + } + if f.Changed("app-code") { + v := devEnvComponentDir(cmd, "app-code") + c.AppCodeDir = &v + } else if interactive { + v, err := appctx.Input(cmd, "Path to local application code (blank for demo)", cur.AppCode.Dir) + if err != nil { + return c, err + } + c.AppCodeDir = &v + } + + // Boolean service toggles — default to the current state. + var err error + if c.Elasticsearch, err = resolveUpdateBool(cmd, "elasticsearch", "Enable Elasticsearch (needed by Enterprise Search)?", rawTruthy(cur.Elasticsearch)); err != nil { + return c, err + } + if c.PHPMyAdmin, err = resolveUpdateBool(cmd, "phpmyadmin", "Enable phpMyAdmin?", cur.PHPMyAdmin); err != nil { + return c, err + } + if c.Xdebug, err = resolveUpdateBool(cmd, "xdebug", "Enable Xdebug?", cur.Xdebug); err != nil { + return c, err + } + if c.Mailpit, err = resolveUpdateBool(cmd, "mailpit", "Enable Mailpit?", cur.Mailpit); err != nil { + return c, err + } + if c.Photon, err = resolveUpdateBool(cmd, "photon", "Enable Photon?", cur.Photon); err != nil { + return c, err + } + if c.Cron, err = resolveUpdateBool(cmd, "cron", "Enable cron?", cur.Cron); err != nil { + return c, err + } + + // xdebug_config — flag only (Node does not prompt for it). + if f.Changed("xdebug_config") { + v, _ := devEnvXdebugConfig(cmd) + c.XdebugConfig = &v + } + + // media-redirect-domain — default to the current value. + if f.Changed("media-redirect-domain") { + v, err := devEnvMediaRedirectDomain(cmd) + if err != nil { + return c, err + } + c.MediaDomain = &v + } else if interactive { + v, err := appctx.Input(cmd, "Redirect missing media to domain (blank to disable)", cur.MediaRedirectDomain) + if err != nil { + return c, err + } + c.MediaDomain = &v + } + + return c, nil +} + +// resolveUpdateBool returns the coerced y/n flag when set, else prompts +// (interactive, defaulting to current), else nil (leave unchanged). +func resolveUpdateBool(cmd *cobra.Command, name, prompt string, current bool) (*bool, error) { + if cmd.Flags().Changed(name) { + v := devEnvServiceFlag(cmd, name) + return &v, nil + } + if appctx.IsInteractive(cmd) { + v, err := appctx.Confirm(cmd, prompt, current) + if err != nil { + return nil, err + } + return &v, nil + } + return nil, nil +} + +// currentPHPVersion extracts the bare PHP version from instance-data's php field, +// which may be a full image reference ("…/php-fpm:8.2") or a bare version. +func currentPHPVersion(cur *instancedata.InstanceData) string { + if i := strings.LastIndex(cur.PHP, ":"); i >= 0 { + return cur.PHP[i+1:] + } + return cur.PHP +} + +// rawTruthy interprets a bool|string JSON union (e.g. elasticsearch) as a bool. +func rawTruthy(r json.RawMessage) bool { + if len(r) == 0 { + return false + } + var b bool + if json.Unmarshal(r, &b) == nil { + return b + } + var s string + if json.Unmarshal(r, &s) == nil { + return s != "" + } + return false +} diff --git a/cmd/vip-next/commands/devenv_update_test.go b/cmd/vip-next/commands/devenv_update_test.go new file mode 100644 index 000000000..959f37f77 --- /dev/null +++ b/cmd/vip-next/commands/devenv_update_test.go @@ -0,0 +1,83 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestResolveUpdateConfigNonInteractiveFlagsOnly(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := newDevEnvUpdateCmd() + if err := c.Flags().Parse([]string{"--php", "8.4", "--phpmyadmin"}); err != nil { + t.Fatal(err) + } + cur := &instancedata.InstanceData{PHP: "8.2", PHPMyAdmin: false} + got, err := resolveUpdateConfig(c, cur) + if err != nil { + t.Fatal(err) + } + if got.PHP == nil || *got.PHP != "8.4" { + t.Fatalf("PHP = %v, want 8.4", got.PHP) + } + if got.PHPMyAdmin == nil || !*got.PHPMyAdmin { + t.Fatalf("phpmyadmin = %v, want true", got.PHPMyAdmin) + } + // Unflagged fields stay nil (unchanged) in non-interactive mode. + if got.WordPress != nil || got.Xdebug != nil || got.MuPluginsDir != nil { + t.Fatalf("unflagged fields must be nil: %+v", got) + } +} + +func TestSelectWithDefaultNonInteractive(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + c := &cobra.Command{} + got, err := selectWithDefault(c, "x", []string{"a", "b", "c"}, "b") + if err != nil || got != "b" { + t.Fatalf("default-in-options: got %q,%v want b", got, err) + } + got, _ = selectWithDefault(c, "x", []string{"a", "b"}, "z") + if got != "a" { + t.Fatalf("default-not-in-options: got %q want a (first)", got) + } +} + +func TestCurrentPHPVersion(t *testing.T) { + cases := map[string]string{ + "ghcr.io/automattic/vip-container-images/php-fpm:8.2": "8.2", + "8.4": "8.4", + "": "", + } + for in, want := range cases { + if got := currentPHPVersion(&instancedata.InstanceData{PHP: in}); got != want { + t.Errorf("currentPHPVersion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestPHPLabelForVersion(t *testing.T) { + if phpLabelForVersion("8.2") != "8.2 (recommended)" { + t.Fatal("8.2 should map to the recommended label") + } + if phpLabelForVersion("9.9") != "" { + t.Fatal("unknown version should map to empty") + } +} + +func TestRawTruthy(t *testing.T) { + if !rawTruthy(json.RawMessage("true")) { + t.Fatal("true should be truthy") + } + if rawTruthy(json.RawMessage("false")) { + t.Fatal("false should not be truthy") + } + if !rawTruthy(json.RawMessage(`"subdomain"`)) { + t.Fatal("non-empty string should be truthy") + } + if rawTruthy(nil) { + t.Fatal("nil should not be truthy") + } +} diff --git a/cmd/vip-next/commands/devenv_versions_proxy_test.go b/cmd/vip-next/commands/devenv_versions_proxy_test.go new file mode 100644 index 000000000..c2d76b40f --- /dev/null +++ b/cmd/vip-next/commands/devenv_versions_proxy_test.go @@ -0,0 +1,133 @@ +package commands + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + xproxy "golang.org/x/net/http/httpproxy" +) + +// proxyEnvVars is every variable internal/httpproxy consults, cleared so an +// ambient shell (or `make test-parity-unit-hostile`) cannot decide the result. +var proxyEnvVars = []string{ + "VIP_PROXY", "vip_proxy", + "SOCKS_PROXY", "socks_proxy", + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + "VIP_USE_SYSTEM_PROXY", + "npm_config_proxy", "npm_config_https_proxy", "npm_config_http_proxy", "npm_config_no_proxy", +} + +func clearProxyEnvVars(t *testing.T) { + t.Helper() + for _, k := range proxyEnvVars { + t.Setenv(k, "") + } +} + +func closedLoopback(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + return addr +} + +// assertNoStdlibProxy is the guard against a vacuous pass. Neither net/http's +// resolver nor x/net's will ever proxy a loopback host, so a test that only +// asserted "the request failed" could be passing for a reason unrelated to the +// fix. Pinning that the stdlib resolver declines this target makes the contrast +// explicit: the OLD code, on http.DefaultTransport, reached the server. +func assertNoStdlibProxy(t *testing.T, rawURL string) { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse %q: %v", rawURL, err) + } + got, err := xproxy.FromEnvironment().ProxyFunc()(u) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if got != nil { + t.Fatalf("precondition failed: the stdlib resolver picked %s for %s, so this test could "+ + "pass without the fix", got, rawURL) + } +} + +// TestWordPressVersionChoicesHonoursVIPProxy is the direct parity assertion for +// the one dev-environment request Node routes through createProxyAgent. +// +// Node: fetchVersionList (src/lib/dev-environment/dev-environment-core.ts:1044) +// builds the raw.githubusercontent.com URL, calls createProxyAgent(url), and +// passes the agent to fetch. Go fetched the same manifest on +// http.DefaultTransport, which ignores VIP_PROXY/SOCKS_PROXY outright and +// honours HTTPS_PROXY without the VIP_USE_SYSTEM_PROXY opt-in — the exact +// inversion internal/httpproxy exists to correct. +// +// The contrast is what makes this test mean something. The target is a loopback +// httptest server, and NEITHER net/http's resolver nor x/net's will ever proxy +// a loopback host — so a test that merely asserted "the fetch failed" would +// have passed for the wrong reason, or passed vacuously before the fix. The +// stdlib assertion below pins that: with only VIP_PROXY set, the stdlib +// resolver selects NO proxy and the old code reached the server and returned +// every tag. Node's proxy-from-env has no loopback exemption, so ours must +// attempt the dead SOCKS port and degrade to trunk. +func TestWordPressVersionChoicesHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"tag":"6.5"},{"tag":"6.4"}]`)) + })) + defer srv.Close() + + clearProxyEnvVars(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopback(t)) + + assertNoStdlibProxy(t, srv.URL) + + restore := wordpressVersionsURL + wordpressVersionsURL = srv.URL + defer func() { wordpressVersionsURL = restore }() + + got := wordpressVersionChoices() + if len(got) != 1 || got[0] != "trunk" { + t.Fatalf("wordpressVersionChoices() = %v; VIP_PROXY was ignored and the manifest was "+ + "fetched directly, which Node would not have done", got) + } +} + +// TestWordPressVersionChoicesReachesTheManifestWithoutAProxy is the other half: +// the fix must not turn every version list into a bare "trunk". +func TestWordPressVersionChoicesReachesTheManifestWithoutAProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"tag":"6.5"},{"tag":"6.4"}]`)) + })) + defer srv.Close() + + clearProxyEnvVars(t) + + restore := wordpressVersionsURL + wordpressVersionsURL = srv.URL + defer func() { wordpressVersionsURL = restore }() + + got := wordpressVersionChoices() + want := []string{"trunk", "6.5", "6.4"} + if len(got) != len(want) { + t.Fatalf("wordpressVersionChoices() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("wordpressVersionChoices()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/cmd/vip-next/commands/export.go b/cmd/vip-next/commands/export.go new file mode 100644 index 000000000..7535199f6 --- /dev/null +++ b/cmd/vip-next/commands/export.go @@ -0,0 +1,16 @@ +package commands + +import "github.com/spf13/cobra" + +// ExportCmd returns the `vip export` parent. Children attach in root.go; +// the parent itself just prints help (Node: src/bin/vip-export.js). +func ExportCmd() *cobra.Command { + return &cobra.Command{ + Use: "export", + Short: "Export data from an environment", + Long: "Export data (SQL database backups) from a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/export_sql.go b/cmd/vip-next/commands/export_sql.go new file mode 100644 index 000000000..53f0f68d6 --- /dev/null +++ b/cmd/vip-next/commands/export_sql.go @@ -0,0 +1,333 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + jsonv1 "encoding/json" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/backup" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/sqlexport" + "github.com/Automattic/vip/internal/tui" +) + +// ExportSQLCmd returns `vip export sql`. +// +// Node parity: src/bin/vip-export-sql.js + src/commands/export-sql.ts. +// Full exports ride the latest db_backup (optionally regenerating it); +// the --table/--site-id/--wpcli-command/--config-file options switch to +// the live-backup-copy (partial export) flow. +func ExportSQLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sql", + Short: "Download a copy of the most recent database backup for an environment", + Long: "Download an archived copy of the most recent database backup for a VIP Platform " + + "environment, or generate and download a partial database export.", + Args: cobra.NoArgs, + } + cmd.Flags().StringP("output", "o", "", "Download the file to a specific local directory path with a custom file name.") + cmd.Flags().StringArrayP("table", "t", nil, "The name of a table to include in the partial database export. Accepts a string value and can be passed more than once with a different value, or add multiple values in a comma-separated list.") + cmd.Flags().StringArrayP("site-id", "s", nil, "The ID of a network site to include in the partial database export. Accepts an integer value and can be passed more than once with a different value, or add multiple values in a comma-separated list.") + cmd.Flags().StringP("wpcli-command", "w", "", "Run a custom WP-CLI command that has logic to retrieve specific data for the partial database export.") + cmd.Flags().StringP("config-file", "c", "", "A local configuration file that specifies the data to include in the partial database export. Accepts a relative or absolute path to the file.") + cmd.Flags().BoolP("generate-backup", "g", false, "Generate a fresh database backup and export an archived copy of that backup.") + cmd.Flags().Bool("skip-download", false, "Skip downloading the file.") + + addAppEnvFlags(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runExportSQL) +} + +// exportPollInterval — VIP_EXPORT_SQL_INTERVAL_MS overrides the 1s Node +// default for tests. +func exportPollInterval() time.Duration { + if v := os.Getenv("VIP_EXPORT_SQL_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return sqlexport.DefaultPollInterval +} + +// exportPollTimeout — VIP_EXPORT_SQL_TIMEOUT_MS overrides Node's 6h pollUntil +// ceiling (export-sql.ts:547,555 → utils.ts:18) so the ceiling is reachable +// in a test. Same knob shape as VIP_EXPORT_SQL_INTERVAL_MS. +func exportPollTimeout() time.Duration { + if v := os.Getenv("VIP_EXPORT_SQL_TIMEOUT_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return sqlexport.DefaultPollTimeout +} + +// fetchBackupAndJobs flattens AppBackupAndJobStatus (export-sql.ts:103). +func fetchBackupAndJobs(ctx context.Context, appID, envID int64) (*sqlexport.BackupAndJobs, error) { + cfg := GetConfig() + resp, err := gql.AppBackupAndJobStatus(ctx, cfg.GQLClient, appID, envID) + if err != nil { + return nil, err + } + st := &sqlexport.BackupAndJobs{} + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + return st, nil + } + env := resp.App.Environments[0] + if env.BackupsSqlDumpTool != nil { + st.EnvSQLDumpTool = *env.BackupsSqlDumpTool + } + if lb := env.LatestBackup; lb != nil { + b := &sqlexport.Backup{} + if lb.Id != nil { + b.ID = int64(*lb.Id) // schema Backup.id is a Float + } + if lb.SqlDumpTool != nil { + b.SQLDumpTool = *lb.SqlDumpTool + } + if lb.CreatedAt != nil { + b.CreatedAt = *lb.CreatedAt + } + st.LatestBackup = b + } + for _, j := range env.Jobs { + if j == nil { + continue + } + job := *j + ej := sqlexport.ExportJob{StepStatus: map[string]string{}} + for _, m := range job.GetMetadata() { + if m == nil || m.Name == nil || m.Value == nil { + continue + } + switch *m.Name { + case "backupId": + if n, err := strconv.ParseInt(*m.Value, 10, 64); err == nil { + ej.BackupID = n + } + case "uploadPath": + ej.UploadPath = *m.Value + case "bytesWritten": + ej.BytesWritten = *m.Value + } + } + if p := job.GetProgress(); p != nil { + for _, s := range p.Steps { + if s == nil || s.Id == nil || s.Status == nil { + continue + } + ej.StepStatus[*s.Id] = *s.Status + } + } + st.Jobs = append(st.Jobs, ej) + } + return st, nil +} + +// buildExportDeps assembles the M7 SQL-export side effects (backup status, +// export job, download link, backup generation, live-copy) around the GQL +// client. Shared by `export sql` and `dev-env sync sql` so both flows drive the +// same platform machinery. +func buildExportDeps(cmd *cobra.Command, appID, envID int64, out io.Writer) sqlexport.Deps { + cfg := GetConfig() + return sqlexport.Deps{ + FetchStatus: func(ctx context.Context) (*sqlexport.BackupAndJobs, error) { + return fetchBackupAndJobs(ctx, appID, envID) + }, + CreateExport: func(ctx context.Context, backupID int64) error { + bid := float64(backupID) + _, err := gql.BackupDBCopy(ctx, cfg.GQLClient, &gql.AppEnvironmentStartDBBackupCopyInput{ + Id: &appID, EnvironmentId: &envID, BackupId: &bid, + }) + return err + }, + GenerateLink: func(ctx context.Context, backupID int64) (string, error) { + bid := float64(backupID) + resp, err := gql.GenerateDBBackupCopyUrl(ctx, cfg.GQLClient, &gql.AppEnvironmentGenerateDBBackupCopyUrlInput{ + Id: &appID, EnvironmentId: &envID, BackupId: &bid, + }) + if err != nil { + return "", err + } + if resp == nil || resp.GenerateDBBackupCopyUrl == nil || resp.GenerateDBBackupCopyUrl.Url == nil { + return "", nil // Node: response... ?? '' (export-sql.ts:179) + } + return *resp.GenerateDBBackupCopyUrl.Url, nil + }, + RunBackup: func(ctx context.Context) error { + return backup.Run(ctx, backup.RunOpts{ + Fetch: func(ctx context.Context) (*backup.Job, error) { + return fetchBackupJob(ctx, appID, envID) + }, + Create: func(ctx context.Context) error { + input := &gql.AppEnvironmentTriggerDBBackupInput{Id: appID, EnvironmentId: envID} + _, err := gql.TriggerDatabaseBackup(ctx, cfg.GQLClient, input) + return err + }, + Tracker: tui.NewProgressTracker([]tui.ProgressStep{ + {ID: backup.StepPrepare, Name: "Preparing for backup generation"}, + {ID: backup.StepGenerate, Name: "Generating backup"}, + }), + Interval: backupPollInterval(), + Timeout: backupPollTimeout(), + Log: func(msg string) { fmt.Fprintln(out, msg) }, + }) + }, + // lcfg is the finished JSON document for the `config: JSON` scalar — + // for --config-file it carries every key the user wrote, so it must + // go on the wire untouched. + StartLive: func(ctx context.Context, lcfg []byte) (string, error) { + rawMsg := jsonv1.RawMessage(lcfg) + resp, err := gql.StartLiveBackupCopy(ctx, cfg.GQLClient, &gql.LiveBackupCopyConfigInput{ + Id: appID, EnvironmentId: envID, Config: &rawMsg, + }) + if err != nil { + return "", err + } + if resp == nil || resp.StartLiveBackupCopy == nil || resp.StartLiveBackupCopy.CopyId == nil || + *resp.StartLiveBackupCopy.CopyId == "" { + msg := "Unknown error" + if resp != nil && resp.StartLiveBackupCopy != nil && resp.StartLiveBackupCopy.Message != nil && + *resp.StartLiveBackupCopy.Message != "" { + msg = *resp.StartLiveBackupCopy.Message + } + return "", fmt.Errorf("Failed to start partial database export: %s", msg) + } + return *resp.StartLiveBackupCopy.CopyId, nil + }, + PollLiveURL: func(ctx context.Context, copyID string) (string, int64, error) { + return pollLiveBackupURL(ctx, appID, envID, copyID) + }, + Confirm: func(message string) (bool, error) { + return importConfirmPrompt(cmd, message, false) + }, + FreeBytes: func() (int64, error) { + return sqlexport.FreeBytesAt(sqlexport.VipDataPath()) + }, + Download: sqlexport.DownloadFile, + } +} + +func runExportSQL(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + out := cmd.OutOrStdout() + + output, _ := cmd.Flags().GetString("output") + tables, _ := cmd.Flags().GetStringArray("table") + siteIDs, _ := cmd.Flags().GetStringArray("site-id") + wpcliCommand, _ := cmd.Flags().GetString("wpcli-command") + configFile, _ := cmd.Flags().GetString("config-file") + generateBackup, _ := cmd.Flags().GetBool("generate-backup") + skipDownload, _ := cmd.Flags().GetBool("skip-download") + + liveCopy, err := sqlexport.ParseLiveCopyCLIOptions(configFile, tables, siteIDs, wpcliCommand) + if err != nil { + return err + } + + trackEvent("export_sql_execute", map[string]any{ + "generate_backup": generateBackup, "live_backup_copy": liveCopy.UseLiveBackupCopy, + }) + + if output != "" { + // Node getAbsolutePath resolves ~ and relative paths (utils). + if strings.HasPrefix(output, "~"+string(filepath.Separator)) { + if home, herr := os.UserHomeDir(); herr == nil { + output = filepath.Join(home, output[2:]) + } + } + if abs, aerr := filepath.Abs(output); aerr == nil { + output = abs + } + } + + pt := tui.NewProgressTracker(sqlexport.Steps()) + renderer := startImportProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + pollCtx := gql.WithAllowGQLErrors(cmd.Context()) + appID := ae.App.ID + envID := ae.Env.ID + + deps := buildExportDeps(cmd, appID, envID, out) + + savedPath, err := sqlexport.Run(pollCtx, pt, sqlexport.Options{ + OutputFile: output, + GenerateBackup: generateBackup, + SkipDownload: skipDownload, + LiveCopy: liveCopy, + Interval: exportPollInterval(), + Timeout: exportPollTimeout(), + AppID: appID, + AppName: ae.App.Name, + EnvUniqueLabel: ae.Env.UniqueLabel, + }, deps, out) + renderer.stop(cmd, true) + if err != nil { + return err + } + // Print AFTER stopping the renderer (export-sql.ts:480). Emitting it while + // the renderer is still animating would shift the cursor and duplicate the + // top progress line. + if savedPath != "" { + fmt.Fprintf(out, "File saved to %s\n", savedPath) + } + trackEvent("export_sql_success", nil) + return nil +} + +// pollLiveBackupURL ports getDownloadURL (live-backup-copy.ts:166): poll +// the mutation every 5s for up to 2h until url is set and processing is +// false. VIP_EXPORT_SQL_INTERVAL_MS shrinks the interval in tests. +func pollLiveBackupURL(ctx context.Context, appID, envID int64, copyID string) (string, int64, error) { + cfg := GetConfig() + interval := 5 * time.Second + if v := os.Getenv("VIP_EXPORT_SQL_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + interval = time.Duration(n) * time.Millisecond + } + } + const timeoutSeconds = 2 * 60 * 60 + deadline := time.Now().Add(timeoutSeconds * time.Second) + + for { + resp, err := gql.GenerateLiveBackupCopyDownloadURL(ctx, cfg.GQLClient, + &gql.AppEnvironmentLiveBackupCopyDownloadURLInput{ + Id: appID, EnvironmentId: envID, CopyId: copyID, + }) + if err != nil { + return "", 0, fmt.Errorf("Failed to generate download URL: %s", err.Error()) + } + r := resp.GenerateLiveBackupCopyDownloadURL + if r != nil && r.Url != nil && *r.Url != "" && !r.Processing { + if !r.Success || r.Size == nil || *r.Size == 0 { + return "", 0, fmt.Errorf("Failed to generate download URL: %s", *r.Url) + } + return *r.Url, *r.Size, nil + } + if time.Now().After(deadline) { + return "", 0, fmt.Errorf("Failed to generate download URL: Polling timed out after %d seconds", timeoutSeconds) + } + select { + case <-ctx.Done(): + return "", 0, ctx.Err() + case <-time.After(interval): + } + } +} diff --git a/cmd/vip-next/commands/export_sql_test.go b/cmd/vip-next/commands/export_sql_test.go new file mode 100644 index 000000000..6a0987d08 --- /dev/null +++ b/cmd/vip-next/commands/export_sql_test.go @@ -0,0 +1,321 @@ +package commands + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + json "encoding/json/v2" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/sqlexport" +) + +// exportStub serves AppBackupAndJobStatus (sequenced), the copy/link +// mutations, and a download target. +type exportStub struct { + statusBodies []string + statusHits atomic.Int32 + copyHits atomic.Int32 + linkHits atomic.Int32 + downloadBody string + srvURL string +} + +func exportStatusBody(withJob bool, preflight, uploadBackup string) string { + jobs := "[]" + if withJob { + jobs = `[{"__typename":"Job","id":5,"type":"db_backup_copy","completedAt":null,"createdAt":"2026-06-11 10:05:00", + "inProgressLock":false, + "metadata":[{"name":"backupId","value":"11"},{"name":"bytesWritten","value":"2048"},{"name":"uploadPath","value":"exports/file.sql.gz"}], + "progress":{"status":"running","steps":[ + {"id":"preflight","name":"Preflight","step":"preflight","status":"` + preflight + `"}, + {"id":"upload_backup","name":"Upload","step":"upload_backup","status":"` + uploadBackup + `"}]}}]` + } + return `{"data":{"app":{"id":42,"environments":[{"id":7,"backupsSqlDumpTool":"mysqldump", + "latestBackup":{"id":11,"type":"daily","size":1024,"filename":"backup.sql.gz","sqlDumpTool":"mysqldump","createdAt":"2026-06-11 10:00:00"}, + "jobs":` + jobs + `}]}}}` +} + +func (s *exportStub) start(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + s.srvURL = srv.URL + + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"AppBackupAndJobStatus"`): + i := int(s.statusHits.Add(1) - 1) + if i >= len(s.statusBodies) { + i = len(s.statusBodies) - 1 + } + _, _ = w.Write([]byte(s.statusBodies[i])) + case strings.Contains(bs, `"operationName":"BackupDBCopy"`): + s.copyHits.Add(1) + _, _ = w.Write([]byte(`{"data":{"startDBBackupCopy":{"message":"ok","success":true}}}`)) + case strings.Contains(bs, `"operationName":"GenerateDBBackupCopyUrl"`): + s.linkHits.Add(1) + fmt.Fprintf(w, `{"data":{"generateDBBackupCopyUrl":{"url":"%s/download","success":true}}}`, s.srvURL) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + mux.HandleFunc("/download", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(s.downloadBody))) + _, _ = w.Write([]byte(s.downloadBody)) + }) + return srv +} + +func setupExportTest(t *testing.T, stub *exportStub) { + t.Helper() + srv := stub.start(t) + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), APIHost: srv.URL, Token: "tok"}) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_EXPORT_SQL_INTERVAL_MS", "1") +} + +// TestExportPollTimeoutKnob: `vip export sql` inherits Node's 6h pollUntil +// ceiling (export-sql.ts:547,555 pass no timeout), overridable with the same +// VIP_*_MS knob shape as VIP_EXPORT_SQL_INTERVAL_MS. +func TestExportPollTimeoutKnob(t *testing.T) { + if got := exportPollTimeout(); got != sqlexport.DefaultPollTimeout { + t.Errorf("exportPollTimeout() = %v, want %v", got, sqlexport.DefaultPollTimeout) + } + t.Setenv("VIP_EXPORT_SQL_TIMEOUT_MS", "25") + if got := exportPollTimeout(); got != 25*time.Millisecond { + t.Errorf("with VIP_EXPORT_SQL_TIMEOUT_MS=25: %v, want 25ms", got) + } +} + +// TestExportSQLStopsAtPollCeiling drives the whole command against an export +// job whose preflight step never succeeds. Before the ceiling was ported this +// spun forever with nothing cancelling the context. +func TestExportSQLStopsAtPollCeiling(t *testing.T) { + stub := &exportStub{ + statusBodies: []string{exportStatusBody(true, "running", "running")}, + downloadBody: "unused", + } + setupExportTest(t, stub) + t.Setenv("VIP_EXPORT_SQL_TIMEOUT_MS", "30") + restore := stubImportPrompts("unused", true) + defer restore() + + cmd := ExportSQLCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + done := make(chan error, 1) + go func() { done <- runExportSQL(cmd, nil) }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "Polling timed out") { + t.Errorf("err = %v, want a %q failure", err, "Polling timed out") + } + case <-time.After(5 * time.Second): + t.Fatal("runExportSQL never returned: the poll loop is unbounded") + } +} + +func TestExportSQLHappyPath(t *testing.T) { + stub := &exportStub{ + statusBodies: []string{ + exportStatusBody(false, "", ""), // initial: no job → CreateExport + exportStatusBody(true, "success", "running"), // preflight done + exportStatusBody(true, "success", "success"), // upload done + }, + downloadBody: "sql-archive-bytes", + } + setupExportTest(t, stub) + restore := stubImportPrompts("unused", true) + defer restore() + + outPath := filepath.Join(t.TempDir(), "export.sql.gz") + cmd := ExportSQLCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + _ = cmd.Flags().Set("output", outPath) + + if err := runExportSQL(cmd, nil); err != nil { + t.Fatalf("runExportSQL: %v\nstdout: %s", err, stdout.String()) + } + if stub.copyHits.Load() != 1 || stub.linkHits.Load() != 1 { + t.Errorf("copy=%d link=%d", stub.copyHits.Load(), stub.linkHits.Load()) + } + got, err := os.ReadFile(outPath) // #nosec G304 + if err != nil || string(got) != "sql-archive-bytes" { + t.Errorf("downloaded = %q err=%v", got, err) + } + if !strings.Contains(stdout.String(), "File saved to "+outPath) { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestExportSQLSkipDownload(t *testing.T) { + stub := &exportStub{ + statusBodies: []string{exportStatusBody(true, "success", "success")}, + downloadBody: "x", + } + setupExportTest(t, stub) + + cmd := ExportSQLCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + _ = cmd.Flags().Set("skip-download", "true") + + if err := runExportSQL(cmd, nil); err != nil { + t.Fatal(err) + } + if strings.Contains(stdout.String(), "File saved to") { + t.Errorf("skip-download must not save: %q", stdout.String()) + } + // Attaching message since the job already exists. + if stub.copyHits.Load() != 0 { + t.Error("BackupDBCopy must not fire when a matching export job exists") + } +} + +func TestExportSQLConfigFileConflict(t *testing.T) { + stub := &exportStub{statusBodies: []string{exportStatusBody(false, "", "")}} + setupExportTest(t, stub) + + cmd := ExportSQLCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + _ = cmd.Flags().Set("config-file", "cfg.json") + _ = cmd.Flags().Set("table", "wp_posts") + + err := runExportSQL(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options.") { + t.Errorf("err = %v", err) + } +} + +// TestExportSQLConfigFileReachesTheWireIntact is the end-to-end half of +// register 2.18. The unit tests prove BuildConfig keeps every key; this one +// proves the plumbing between BuildConfig and +// LiveBackupCopyConfigInput.config doesn't re-narrow it. If any key is lost +// here the user gets a dump with the wrong scope AND exit 0 — nothing in the +// output signals it, which is what makes this the highest-severity item in +// the slice. +func TestExportSQLConfigFileReachesTheWireIntact(t *testing.T) { + configJSON := `{ + "type": "tables", + "tool": "mysqldump", + "tables": {"wp_posts": {"where": "ID > 100", "structure_only": true}}, + "exclude_tables": ["wp_options"], + "limit": 500, + "site_ids": [] + }` + cfgPath := filepath.Join(t.TempDir(), "db-export-config.json") + if err := os.WriteFile(cfgPath, []byte(configJSON), 0o600); err != nil { + t.Fatal(err) + } + + var sentConfig map[string]any + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(string(body), `"operationName":"StartLiveBackupCopy"`): + var req struct { + Variables struct { + Input struct { + Config map[string]any `json:"config"` + } `json:"input"` + } `json:"variables"` + } + if err := json.Unmarshal(body, &req); err != nil { + t.Errorf("decode request: %v", err) + } + sentConfig = req.Variables.Input.Config + _, _ = w.Write([]byte(`{"data":{"startLiveBackupCopy":{"message":"ok","copyId":"copy-9"}}}`)) + case strings.Contains(string(body), `"operationName":"GenerateLiveBackupCopyDownloadURL"`): + fmt.Fprintf(w, `{"data":{"generateLiveBackupCopyDownloadURL":{"success":true,"url":"%s/download","processing":false,"size":17}}}`, srv.URL) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + mux.HandleFunc("/download", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("sql-archive-bytes")) + }) + + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), APIHost: srv.URL, Token: "tok"}) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_EXPORT_SQL_INTERVAL_MS", "1") + restore := stubImportPrompts("unused", true) + defer restore() + + dest := filepath.Join(t.TempDir(), "out.sql.gz") + cmd := ExportSQLCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + _ = cmd.Flags().Set("config-file", cfgPath) + _ = cmd.Flags().Set("output", dest) + + if err := runExportSQL(cmd, nil); err != nil { + t.Fatalf("runExportSQL: %v", err) + } + if sentConfig == nil { + t.Fatal("StartLiveBackupCopy was never called") + } + + if _, ok := sentConfig["exclude_tables"]; !ok { + t.Errorf("exclude_tables never reached the server: %v", sentConfig) + } + if sentConfig["limit"] != float64(500) { + t.Errorf("limit never reached the server: %v", sentConfig) + } + if _, ok := sentConfig["site_ids"]; !ok { + t.Errorf("explicit empty site_ids never reached the server: %v", sentConfig) + } + tables, _ := sentConfig["tables"].(map[string]any) + wpPosts, _ := tables["wp_posts"].(map[string]any) + if wpPosts["where"] != "ID > 100" || wpPosts["structure_only"] != true { + t.Errorf("per-table options never reached the server: %v", sentConfig) + } +} + +func TestExportSQLNoBackup(t *testing.T) { + stub := &exportStub{statusBodies: []string{ + `{"data":{"app":{"id":42,"environments":[{"id":7,"backupsSqlDumpTool":null,"latestBackup":null,"jobs":[]}]}}}`, + }} + setupExportTest(t, stub) + + cmd := ExportSQLCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + err := runExportSQL(cmd, nil) + if err == nil || err.Error() != "No backup found for site parityapp" { + t.Errorf("err = %v", err) + } +} diff --git a/cmd/vip-next/commands/format_env.go b/cmd/vip-next/commands/format_env.go new file mode 100644 index 000000000..f0d4cfff1 --- /dev/null +++ b/cmd/vip-next/commands/format_env.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/Automattic/vip/internal/output" +) + +// formatEnvironment mirrors Node's src/lib/cli/format.ts formatEnvironment: +// production -> red("PRODUCTION") (uppercased), everything else -> blueBright +// (lowercased). Used in inline prod-gate prompts so confirm wording matches +// the Node CLI exactly. NO_COLOR is honored automatically by fatih/color. +// +// The implementation lives in internal/output alongside the rest of the +// format.ts port, because appctx's confirmation info table needs it too and +// cannot import this package. +func formatEnvironment(envType string) string { + return output.FormatEnvironment(envType) +} diff --git a/cmd/vip-next/commands/import.go b/cmd/vip-next/commands/import.go new file mode 100644 index 000000000..daeb48f99 --- /dev/null +++ b/cmd/vip-next/commands/import.go @@ -0,0 +1,19 @@ +package commands + +import "github.com/spf13/cobra" + +// ImportCmd returns the `vip import` parent. Children attach in root.go; +// the parent itself just prints help. +// +// M6b adds validate-sql; later milestones will add sql + media + the file +// validators. +func ImportCmd() *cobra.Command { + return &cobra.Command{ + Use: "import", + Short: "Validate and import data into a VIP Platform environment", + Long: "Validate and import data (SQL dumps, media files) into a VIP Platform environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } +} diff --git a/cmd/vip-next/commands/import_media.go b/cmd/vip-next/commands/import_media.go new file mode 100644 index 000000000..8ea62076d --- /dev/null +++ b/cmd/vip-next/commands/import_media.go @@ -0,0 +1,429 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + json "encoding/json/v2" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/httpproxy" + "github.com/Automattic/vip/internal/mediaimport" + "github.com/Automattic/vip/internal/redact" + "github.com/Automattic/vip/internal/siteimport" + "github.com/Automattic/vip/internal/upload" +) + +// mediaImportAPIVersion — API_VERSION (vip-import-media.js:21). +const mediaImportAPIVersion = "v2" + +// ImportMediaCmd returns `vip import media <file|url>`. +// +// Node parity: src/bin/vip-import-media.js. URL or local archive +// (.tar.gz/.tgz/.zip); local archives upload via internal/upload and the +// platform fetches the resulting presigned GetObject URL. Invalid +// input prints a red error block and exits 0 (js:158-176). +func ImportMediaCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "media <file|url>", + Short: "Import media files into an environment", + Long: "Import an archive of media files (.tar.gz, .tgz, .zip) from a local path or a publicly " + + "accessible URL into a VIP Platform environment. The command polls the import status until completion.", + Args: cobra.ExactArgs(1), + } + cmd.Flags().Bool("exportFileErrorsToJson", false, "Format the error log in JSON. Default is TXT.") + cmd.Flags().StringP("saveErrorLog", "s", "", "Skip the confirmation prompt and download an error log for the import automatically.") + cmd.Flags().BoolP("overwriteExistingFiles", "o", false, "Overwrite existing files with the imported files if they have the same path and file name.") + cmd.Flags().BoolP("importIntermediateImages", "i", false, "Include intermediate image files in the import.") + + addAppEnvFlags(cmd) + addSkipConfirmationWithForceAlias(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithSkipConfirmationFlag(cmd), + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + appctx.WithRequireConfirm(cmd, importMediaConfirmMessage(), importMediaConfirmPayload), + ).WithRun(runImportMedia) +} + +// importMediaConfirmMessage — the requireConfirm block from +// vip-import-media.js:108. +func importMediaConfirmMessage() string { + bold := color.New(color.FgRed, color.Bold) + return "\n" + bold.Sprint("NOTE: If the provided archive's directory structure contains an `uploads/` directory,") + + "\n" + bold.Sprint("only the files present inside that directory will be imported and the rest will be ignored.") + + "\n" + bold.Sprint("If no `uploads/` directory is found, all files will be imported, as is.") + + "\n\nAre you sure you want to import the contents of the URL?\n" +} + +// isSupportedMediaURL ports isSupportedUrl (vip-import-media.js:91). +func isSupportedMediaURL(urlToTest string) bool { + u, err := url.Parse(urlToTest) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +func runImportMedia(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + fileNameOrURL := args[0] + + exportJSON, _ := cmd.Flags().GetBool("exportFileErrorsToJson") + // Node negotiates --saveErrorLog into exactly "true"/"false"/"prompt" + // for module 'import-media' BEFORE the handler runs (command.js:829-837), + // which is also why an absent flag still prompts. See + // negotiateSaveErrorLog in import_media_confirm.go. + rawSaveErrorLog, _ := cmd.Flags().GetString("saveErrorLog") + saveErrorLog := negotiateSaveErrorLog(rawSaveErrorLog) + overwriteExistingFiles, _ := cmd.Flags().GetBool("overwriteExistingFiles") + importIntermediateImages, _ := cmd.Flags().GetBool("importIntermediateImages") + + archiveURL := "" + sourceIsLocal := false + + if strings.HasPrefix(fileNameOrURL, "http://") || strings.HasPrefix(fileNameOrURL, "https://") { + archiveURL = fileNameOrURL + if !isSupportedMediaURL(archiveURL) { + // js:158-163 — red block, exit 0. Whitespace verbatim. + fmt.Fprintln(out, color.RedString("\n\t Error:\n\t Invalid URL provided: "+archiveURL+ + "\n\t Please make sure that it is a publicly accessible web URL containing an archive of the media files to import.")) + return nil + } + } else { + if !mediaimport.IsLocalArchive(fileNameOrURL) { + // js:169-174 — red block, exit 0. + fmt.Fprintln(out, color.RedString("\n\t Error:\n\t Invalid local archive provided: "+fileNameOrURL+ + "\n\t Please make sure the file exists and is one of: .tar.gz, .tgz, .zip")) + return nil + } + + sourceIsLocal = true + meta, err := upload.GetFileMeta(fileNameOrURL) + if err != nil { + return err + } + uc := &upload.Client{APIHost: cfg.APIHost, Token: cfg.Token} + lastProgress := "" + res, err := uc.UploadImportFile(cmd.Context(), ae.App.ID, ae.Env.ID, meta, "md5", + func(pct string) { + // js:191-197 — only rewrite the line when the value changed. + if pct == lastProgress { + return + } + lastProgress = pct + fmt.Fprintf(out, "\rUpload progress: %s ", pct) + }) + if err != nil { + return err + } + fmt.Fprint(out, "\n") + + pre, err := uc.GetSignedUploadRequestData(cmd.Context(), upload.SignedRequestArgs{ + Action: "GetObject", AppID: ae.App.ID, EnvID: ae.Env.ID, BaseName: res.Meta.BaseName, + }) + if err != nil { + return err + } + archiveURL = pre.URL + } + + trackEvent("import_media_start_execute", nil) + + tracker := mediaimport.NewTracker() + tracker.SetPrefix("\n=============================================================\nImporting Media into your App...\n") + + // Banner (js:231-237). Domain comes from the env-info query (Node's + // appQuery carried primaryDomain). + domain := mediaPrimaryDomain(cmd.Context(), ae) + fmt.Fprintln(out) + if sourceIsLocal { + fmt.Fprintf(out, "Importing local archive: %s (uploaded to temporary URL)\n", fileNameOrURL) + } else { + fmt.Fprintf(out, "Importing archive from: %s\n", archiveURL) + } + fmt.Fprintf(out, "to: %s (%s)\n", domain, formatEnvironment(ae.Env.Type)) + + appID := ae.App.ID + envID := ae.Env.ID + input := &gql.AppEnvironmentStartMediaImportInput{ + ApplicationId: appID, + EnvironmentId: envID, + ArchiveUrl: archiveURL, + OverwriteExistingFiles: &overwriteExistingFiles, + ImportIntermediateImages: &importIntermediateImages, + } + apiVersion := mediaImportAPIVersion + input.ApiVersion = &apiVersion + + if _, err := gql.StartMediaImport(gql.WithAllowGQLErrors(cmd.Context()), cfg.GQLClient, input); err != nil { + // js:262-268 — print each GraphQL error and exit 0. + fmt.Fprintln(out, color.RedString("Error:"), err.Error()) + trackEvent("import_media_start_execute_error", map[string]any{"error": "Error: " + err.Error()}) + return nil + } + + return mediaImportCheckStatusCmd(cmd, tracker, ae, exportJSON, saveErrorLog) +} + +// mediaPrimaryDomain fetches the env's primary domain name (the Node +// appQuery includes primaryDomain; our resolver doesn't, so reuse the +// import-sql env-info query). Falls back to "N/A". +func mediaPrimaryDomain(ctx context.Context, ae *appctx.AppEnv) string { + cfg := GetConfig() + if info, err := fetchImportEnvInfo(gql.WithAllowGQLErrors(ctx), cfg.GQLClient, ae.App.ID, ae.Env.ID); err == nil && + info.PrimaryDomainName != "" { + return info.PrimaryDomainName + } + return "N/A" +} + +// mediaPollInterval — VIP_IMPORT_MEDIA_INTERVAL_MS overrides the 1s Node +// default for tests. +func mediaPollInterval() time.Duration { + if v := os.Getenv("VIP_IMPORT_MEDIA_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return mediaimport.DefaultPollInterval +} + +// mediaImportCheckStatusCmd ports mediaImportCheckStatus (status.ts:158): +// poll to a terminal state, render the Status/App suffix block, then run +// the error-log download flow. Returns nil on COMPLETED/ABORTED (exit 0) +// and an error on FAILED (exit 1, status.ts:384). +func mediaImportCheckStatusCmd(cmd *cobra.Command, tracker *mediaimport.Tracker, ae *appctx.AppEnv, exportJSON bool, saveErrorLog string) error { + cfg := GetConfig() + pollCtx := gql.WithAllowGQLErrors(cmd.Context()) + + fetch := func(ctx context.Context) (*mediaimport.Status, error) { + appID := ae.App.ID + envID := ae.Env.ID + resp, err := gql.MediaImportProgress(ctx, cfg.GQLClient, &appID, &envID) + if err != nil { + return nil, err + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + // status.ts:75. + return nil, errors.New("Unable to determine import status from environment") + } + mis := resp.App.Environments[0].MediaImportStatus + if mis == nil { + return nil, nil + } + st := &mediaimport.Status{} + if mis.ImportId != nil { + st.ImportID = *mis.ImportId + } + if mis.SiteId != nil { + st.SiteID = *mis.SiteId + } + if mis.Status != nil { + st.Status = *mis.Status + } + if mis.FilesTotal != nil { + st.FilesTotal = *mis.FilesTotal + } + if mis.FilesProcessed != nil { + st.FilesProcessed = *mis.FilesProcessed + st.HasFilesProcessed = true + } + if fd := mis.FailureDetails; fd != nil { + det := &mediaimport.FailureDetails{} + if fd.PreviousStatus != nil { + det.PreviousStatus = *fd.PreviousStatus + } + for _, g := range fd.GlobalErrors { + if g != nil { + det.GlobalErrors = append(det.GlobalErrors, *g) + } + } + if fd.FileErrorsUrl != nil { + det.FileErrorsURL = *fd.FileErrorsUrl + } + st.FailureDetails = det + } + return st, nil + } + + setSuffix := func(overall string) { + // status.ts:178-207. The spinner glyph in the suffix uses the + // first frame; the tracker's own line animates. + sprite := mediaimport.GlyphForMediaStatus(overall, "⠋") + var statusMessage string + switch overall { + case "INITIALIZING": + statusMessage = fmt.Sprintf("INITIALIZING %s : We're downloading the files to be imported...", sprite) + case "COMPLETED": + statusMessage = fmt.Sprintf("COMPLETED %s : The imported files should be visible on your App", sprite) + default: + statusMessage = fmt.Sprintf("%s %s", siteimport.Capitalize(overall), sprite) + } + maybeExitPrompt := "(Press ^C to hide progress. The import will continue in the background.)" + if overall == "COMPLETED" || overall == "ABORTED" || overall == "FAILED" { + maybeExitPrompt = "" + } + tracker.SetSuffix(fmt.Sprintf("\n=============================================================\nStatus: %s\nApp: %s (%s)\n=============================================================\n%s\n", + statusMessage, ae.App.Name, formatEnvironment(ae.Env.Type), maybeExitPrompt)) + } + setSuffix("Checking...") + + renderer := startImportProgressRenderer(cmd, tracker) + defer renderer.stop(cmd, false) + + res, err := mediaimport.CheckStatus(pollCtx, mediaimport.CheckStatusOpts{ + Fetch: fetch, + Tracker: tracker, + Interval: mediaPollInterval(), + OnPoll: setSuffix, + }) + if err != nil { + var fe *mediaimport.MediaImportError + if errors.As(err, &fe) { + renderer.stop(cmd, true) + return errors.New(mediaimport.BuildErrorMessage(fe)) + } + renderer.stop(cmd, true) + return err + } + + overall := res.Status + setSuffix(overall) + + if res.FailureDetails != nil && res.FailureDetails.FileErrorsURL != "" { + if err := promptFailureDetailsDownload(cmd, tracker, ae.App.Name, res.FailureDetails.FileErrorsURL, exportJSON, saveErrorLog); err != nil { + renderer.stop(cmd, true) + return err + } + } else if overall != "ABORTED" { + // status.ts:347-358 — report-link-expired notice. + if res.FilesTotal > 0 && res.HasFilesProcessed && res.FilesTotal != res.FilesProcessed { + errorsFound := res.FilesTotal - res.FilesProcessed + tracker.AppendSuffix(color.YellowString( + fmt.Sprintf("⚠️ %d error(s) were found. File import errors report link expired.", errorsFound))) + } + } + + renderer.stop(cmd, true) + return nil +} + +// fetchFailureDetails downloads and parses the media-import file-errors report +// (Node: fetchFailureDetails, src/lib/media-import/status.ts:296). +// +// Two things make this call site need more care than a plain GET. +// +// The URL is presigned — its query string IS the download credential — and +// net/http embeds the full request URL in every *url.Error it returns. That +// error propagates to exit.WithError and from there to the Go-only cli_error +// telemetry hook, which posts to public-api.wordpress.com. A failed fetch was +// therefore shipping a live credential for a customer's error report off-box. +// redact.Text removes the query while keeping host and path, so the message +// still says what failed and where. +// +// The URL is also entirely server-provided, so the response is treated as +// untrusted input: the body is read through a limit reader rather than straight +// into memory, and a non-2xx status is reported as a status rather than handed +// to the JSON parser (where an S3 XML error body surfaces as the useless +// "invalid character '<'"). +func fetchFailureDetails(fileErrorsURL string) ([]mediaimport.FileError, error) { + // Node uses a bare node-fetch here, which reads no proxy environment at + // all. Go's default is not equivalent to that: it honours an ambient + // HTTPS_PROXY without the VIP_USE_SYSTEM_PROXY opt-in while ignoring + // VIP_PROXY. See internal/httpproxy. + resp, err := httpproxy.Client().Get(fileErrorsURL) // #nosec G107 -- server-provided report URL + if err != nil { + return nil, errors.New(redact.Text(err.Error())) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("import errors report returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxFailureReportBytes)) + if err != nil { + return nil, errors.New(redact.Text(err.Error())) + } + + var fileErrors []mediaimport.FileError + if err := json.Unmarshal(body, &fileErrors); err != nil { + return nil, err + } + return fileErrors, nil +} + +// maxFailureReportBytes caps the untrusted error report. The largest real +// reports are a few MB of filenames; 256 MB is far past any of them and still +// bounds a malformed or hostile response. +const maxFailureReportBytes = 256 << 20 + +// promptFailureDetailsDownload ports promptFailureDetailsDownload +// (status.ts:313): 'prompt' asks; 'true'/'yes' downloads; anything else +// prints the 15-minute URL block. +func promptFailureDetailsDownload(cmd *cobra.Command, tracker *mediaimport.Tracker, appName, fileErrorsURL string, exportJSON bool, saveErrorLog string) error { + download := false + if saveErrorLog == "prompt" { + ok, err := importConfirmPrompt(cmd, + "Download import errors report now? (Report will be downloadable for up to 7 days from the completion of the import)", false) + download = err == nil && ok + } else { + download = saveErrorLog == "true" || saveErrorLog == "yes" + } + + if !download { + // status.ts:327-338. + tracker.AppendSuffix(color.YellowString("⚠️ An error report file has been generated for this media import. Access it within the next 15 minutes by clicking on the URL below.")) + tracker.AppendSuffix("\n" + color.YellowString("Or, generate a new URL by running the "+ + color.New(color.BgYellow).Sprint("vip import media status")+" command.") + " ") + tracker.AppendSuffix("\n" + color.YellowString("The report will be downloadable for up to 7 days after the completion of the import or until a new media import is performed.")) + tracker.AppendSuffix("\n\n" + color.New(color.Underline).Sprint(fileErrorsURL) + "\n") + return nil + } + + // fetchFailureDetails (status.ts:296). + tracker.AppendSuffix(fmt.Sprintf("\n=============================================================\nDownloading errors details from %s\n\n", fileErrorsURL)) + fileErrors, err := fetchFailureDetails(fileErrorsURL) + if err != nil { + tracker.AppendSuffix(color.RedString("Could not download import errors report\n" + err.Error())) + return err + } + + // exportFailureDetails (status.ts:277). + formatted := mediaimport.BuildFileErrors(fileErrors, exportJSON) + ext := ".txt" + if exportJSON { + ext = ".json" + } + errorsFile := fmt.Sprintf("media-import-%s-%d%s", appName, time.Now().UnixMilli(), ext) + if err := os.WriteFile(errorsFile, []byte(formatted), 0o600); err != nil { + tracker.AppendSuffix(color.RedString("Could not export errors to file\n" + err.Error())) + return nil + } + abs, err := filepath.Abs(errorsFile) + if err != nil { + abs = errorsFile + } + tracker.AppendSuffix(color.YellowString("⚠️ All errors have been exported to " + + color.New(color.Bold).Sprint(abs) + "\n")) + return nil +} diff --git a/cmd/vip-next/commands/import_media_abort.go b/cmd/vip-next/commands/import_media_abort.go new file mode 100644 index 000000000..0a3c7ab00 --- /dev/null +++ b/cmd/vip-next/commands/import_media_abort.go @@ -0,0 +1,76 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/mediaimport" +) + +// ImportMediaAbortCmd returns `vip import media abort`. +// +// Node parity: src/bin/vip-import-media-abort.js. requireConfirm gates +// the mutation; GraphQL errors print and exit 0 (js:103-108). +func ImportMediaAbortCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "abort", + Short: "Abort the media import currently in progress", + Long: "Abort the media file import that is currently in progress on an environment. The import process cannot be resumed.", + Args: cobra.NoArgs, + } + addAppEnvFlags(cmd) + addSkipConfirmationWithForceAlias(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithSkipConfirmationFlag(cmd), + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + appctx.WithRequireConfirm(cmd, importMediaAbortConfirmMessage()), + ).WithRun(runImportMediaAbort) +} + +// importMediaAbortConfirmMessage — vip-import-media-abort.js:63. +func importMediaAbortConfirmMessage() string { + bold := color.New(color.FgRed, color.Bold) + return "\n" + bold.Sprint("Running this command will stop the currently running media import. The import process cannot be resumed.") + + "\n" + bold.Sprint("Are you sure you want to abort this media import?") + "\n" +} + +func runImportMediaAbort(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + + if !mediaimport.IsSupportedApp(ae.App.Type) { + trackEvent("import_media_command_error", map[string]any{"errorType": "unsupported-app"}) + // vip-import-media-abort.js:78 wording (differs from status's). + return errors.New("The type of application you specified does not currently support media file imports.") + } + + trackEvent("import_media_abort_execute", nil) + + tracker := mediaimport.NewTracker() + tracker.SetPrefix("\n=============================================================\nAborting this media import.\n") + + input := &gql.AppEnvironmentAbortMediaImportInput{ + ApplicationId: ae.App.ID, + EnvironmentId: ae.Env.ID, + } + if _, err := gql.AbortMediaImport(gql.WithAllowGQLErrors(cmd.Context()), cfg.GQLClient, input); err != nil { + // js:103-108 — print and exit 0. + fmt.Fprintln(out, color.RedString("Error:"), err.Error()) + trackEvent("import_media_abort_execute_error", map[string]any{"error": "Error: " + err.Error()}) + return nil + } + + // Node calls mediaImportCheckStatus without error-log options (js:101). + return mediaImportCheckStatusCmd(cmd, tracker, ae, false, "") +} diff --git a/cmd/vip-next/commands/import_media_confirm.go b/cmd/vip-next/commands/import_media_confirm.go new file mode 100644 index 000000000..3b74bb3c5 --- /dev/null +++ b/cmd/vip-next/commands/import_media_confirm.go @@ -0,0 +1,83 @@ +package commands + +import ( + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// importMediaConfirmPayload is the `case 'import-media'` arm of Node's +// requireConfirm switch (src/lib/cli/command.js:936-980). +// +// Row order, labels and value formatting are Node's verbatim — including the +// full-sentence key "Export any file errors encountered to a JSON file +// instead of a plain text file." and the trailing question mark on +// "Download file-error logs?". +// +// It also carries Node's message rewrite: when the input is a local archive +// rather than an http(s) URL, every occurrence of "the URL" in the confirm +// question becomes "the path" (command.js:944-947). +func importMediaConfirmPayload(cmd *cobra.Command, args []string, message string) ([]output.Tuple, string, error) { + sub := "" + if len(args) > 0 { + sub = args[0] + } + isURL := sub != "" && + (strings.HasPrefix(sub, "http://") || strings.HasPrefix(sub, "https://")) + + archiveLabel := "Archive Path" + if isURL { + archiveLabel = "Archive URL" + } + if !isURL { + message = strings.ReplaceAll(message, "the URL", "the path") + } + + overwrite, _ := cmd.Flags().GetBool("overwriteExistingFiles") + intermediate, _ := cmd.Flags().GetBool("importIntermediateImages") + exportJSON, _ := cmd.Flags().GetBool("exportFileErrorsToJson") + saveErrorLog, _ := cmd.Flags().GetString("saveErrorLog") + + rows := []output.Tuple{ + // chalk.blue.underline in Node; fatih/color drops the escapes under + // NO_COLOR / non-TTY exactly as chalk does. + {Key: archiveLabel, Value: color.New(color.FgBlue, color.Underline).Sprint(sub)}, + {Key: "Overwrite any existing files", Value: yesNoGlyph(overwrite)}, + {Key: "Import intermediate image files", Value: yesNoGlyph(intermediate)}, + {Key: "Export any file errors encountered to a JSON file instead of a plain text file.", Value: yesNoGlyph(exportJSON)}, + {Key: "Download file-error logs?", Value: negotiateSaveErrorLog(saveErrorLog)}, + } + return rows, message, nil +} + +// yesNoGlyph ports Node's ternary: ✅ Yes when on, chalk.red("x") + " No" +// when off (command.js:955). +func yesNoGlyph(v bool) string { + if v { + return "✅ Yes" + } + return color.RedString("x") + " No" +} + +// negotiateSaveErrorLog ports the `_opts.module === 'import-media'` flag +// negotiation at src/lib/cli/command.js:829-837: the raw --saveErrorLog value +// collapses to exactly one of "true" / "false" / "prompt" before the handler +// (and the confirmation table) ever sees it. Anything unrecognized — including +// the flag being absent — becomes "prompt". +// +// This applies to `vip import media` only. `vip import media status` sets no +// module in Node and instead declares "prompt" as the option default, and +// `vip import media abort` passes no error-log options at all. +func negotiateSaveErrorLog(raw string) string { + switch raw { + case "true", "yes": + return "true" + case "false", "no": + return "false" + default: + return "prompt" + } +} diff --git a/cmd/vip-next/commands/import_media_confirm_test.go b/cmd/vip-next/commands/import_media_confirm_test.go new file mode 100644 index 000000000..fa684fd38 --- /dev/null +++ b/cmd/vip-next/commands/import_media_confirm_test.go @@ -0,0 +1,249 @@ +package commands + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// mediaConfirmServer resolves app 42 with a single "production" environment +// and 404s everything else, so any request past the confirm gate is visible. +func mediaConfirmServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(string(body), `"operationName":"ResolveAppByID"`) { + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"my-app","type":"WordPress","typeId":2, + "environments":[{"id":3,"appId":3,"name":"production","type":"production", + "uniqueLabel":"production","defaultDomain":"example.com","isMultisite":false}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":null}`)) + })) +} + +func mediaConfirmCmd(t *testing.T, srv *httptest.Server) (*cobra.Command, *bytes.Buffer) { + t.Helper() + t.Setenv("NO_COLOR", "1") + client := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: client, AppCtxConfig: appctx.AppContextConfig{Client: client}}) + t.Cleanup(func() { SetConfig(Config{}) }) + + cmd := ImportMediaCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + _ = cmd.Flags().Set("app", "42") + cmd.SetContext(context.Background()) + return cmd, &stdout +} + +// command.js:936-980. Defaults: every toggle off ("x No"), the error-log +// choice negotiated to "prompt", and the archive row labelled "Archive URL" +// for an http(s) input. +func TestImportMediaConfirmRendersURLPayload(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + srv := mediaConfirmServer(t) + defer srv.Close() + cmd, stdout := mediaConfirmCmd(t, srv) + + if err := cmd.RunE(cmd, []string{"https://example.com/media.tar.gz"}); err != nil { + t.Fatalf("RunE: %v", err) + } + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: production (id: 3)\n" + + "+ Archive URL: https://example.com/media.tar.gz\n" + + "+ Overwrite any existing files: x No\n" + + "+ Import intermediate image files: x No\n" + + "+ Export any file errors encountered to a JSON file instead of a plain text file.: x No\n" + + "+ Download file-error logs?: prompt\n" + + "===================================\n" + + "Command cancelled\n" + if stdout.String() != want { + t.Errorf("confirm payload mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// A local archive gets the "Archive Path" label instead. +func TestImportMediaConfirmRendersLocalArchivePathLabel(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + srv := mediaConfirmServer(t) + defer srv.Close() + cmd, stdout := mediaConfirmCmd(t, srv) + + archive := filepath.Join(t.TempDir(), "media.tar.gz") + if err := os.WriteFile(archive, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, []string{archive}); err != nil { + t.Fatalf("RunE: %v", err) + } + if !strings.Contains(stdout.String(), "+ Archive Path: "+archive+"\n") { + t.Errorf("want an 'Archive Path' row for a local archive; got %q", stdout.String()) + } + if strings.Contains(stdout.String(), "Archive URL") { + t.Errorf("local archive must not be labelled 'Archive URL'; got %q", stdout.String()) + } +} + +// Flags on -> "✅ Yes", and --saveErrorLog is negotiated to the literal +// "true"/"false"/"prompt" trio before it is displayed (command.js:829-837). +func TestImportMediaConfirmRendersEnabledToggles(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + srv := mediaConfirmServer(t) + defer srv.Close() + cmd, stdout := mediaConfirmCmd(t, srv) + _ = cmd.Flags().Set("overwriteExistingFiles", "true") + _ = cmd.Flags().Set("importIntermediateImages", "true") + _ = cmd.Flags().Set("exportFileErrorsToJson", "true") + _ = cmd.Flags().Set("saveErrorLog", "yes") + + if err := cmd.RunE(cmd, []string{"https://example.com/media.zip"}); err != nil { + t.Fatalf("RunE: %v", err) + } + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: production (id: 3)\n" + + "+ Archive URL: https://example.com/media.zip\n" + + "+ Overwrite any existing files: ✅ Yes\n" + + "+ Import intermediate image files: ✅ Yes\n" + + "+ Export any file errors encountered to a JSON file instead of a plain text file.: ✅ Yes\n" + + "+ Download file-error logs?: true\n" + + "===================================\n" + + "Command cancelled\n" + if stdout.String() != want { + t.Errorf("confirm payload mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// command.js:944-947 — for a local archive the confirm message's "the URL" +// becomes "the path". vip-next always said "the URL". +func TestImportMediaConfirmRewritesURLToPathForLocalArchive(t *testing.T) { + cmd := &cobra.Command{Use: "media"} + cmd.Flags().Bool("exportFileErrorsToJson", false, "") + cmd.Flags().String("saveErrorLog", "", "") + cmd.Flags().Bool("overwriteExistingFiles", false, "") + cmd.Flags().Bool("importIntermediateImages", false, "") + cmd.SetContext(context.Background()) + + const msg = "Are you sure you want to import the contents of the URL?" + + _, urlMessage, err := importMediaConfirmPayload(cmd, []string{"https://example.com/a.zip"}, msg) + if err != nil { + t.Fatalf("payload: %v", err) + } + if urlMessage != msg { + t.Errorf("remote archive message = %q, want it unchanged", urlMessage) + } + + _, pathMessage, err := importMediaConfirmPayload(cmd, []string{"/tmp/a.zip"}, msg) + if err != nil { + t.Fatalf("payload: %v", err) + } + if pathMessage != "Are you sure you want to import the contents of the path?" { + t.Errorf("local archive message = %q", pathMessage) + } +} + +// negotiateSaveErrorLog ports the flag negotiation at command.js:829-837. +func TestNegotiateSaveErrorLog(t *testing.T) { + cases := map[string]string{ + "": "prompt", + "true": "true", + "yes": "yes" + "", // placeholder replaced below + "false": "false", + "no": "false", + "banana": "prompt", + "prompt": "prompt", + } + cases["yes"] = "true" + for in, want := range cases { + if got := negotiateSaveErrorLog(in); got != want { + t.Errorf("negotiateSaveErrorLog(%q) = %q, want %q", in, got, want) + } + } +} + +// `import media abort` sets no `module` in Node, so it renders App and +// Environment only — no archive/toggle rows. +func TestImportMediaAbortConfirmRendersAppEnvOnly(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + srv := mediaConfirmServer(t) + defer srv.Close() + t.Setenv("NO_COLOR", "1") + client := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: client, AppCtxConfig: appctx.AppContextConfig{Client: client}}) + defer SetConfig(Config{}) + + cmd := ImportMediaAbortCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + _ = cmd.Flags().Set("app", "42") + cmd.SetContext(context.Background()) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: production (id: 3)\n" + + "===================================\n" + + "Command cancelled\n" + if stdout.String() != want { + t.Errorf("abort confirm payload mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// The negotiated saveErrorLog value must reach the handler, not just the +// info table: Node turns an absent --saveErrorLog into "prompt", so a +// completed import with a file-errors report ASKS whether to download it. +// vip-next left the flag at "" and silently skipped the question. +func TestImportMediaDefaultSaveErrorLogPrompts(t *testing.T) { + stub := &mediaStub{} + srv := stub.start(t) + stub.mu.Lock() + stub.progressBodies = []string{mediaProgress("COMPLETED", 8, 10, + `,"failureDetails":{"previousStatus":null,"globalErrors":[],"fileErrorsUrl":"`+srv.URL+`/file-errors"}`)} + stub.mu.Unlock() + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_IMPORT_MEDIA_INTERVAL_MS", "1") + + prompted := false + prev := importConfirmPrompt + importConfirmPrompt = func(*cobra.Command, string, bool) (bool, error) { + prompted = true + return false, nil + } + defer func() { importConfirmPrompt = prev }() + + cmd := ImportMediaCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtxWithType(42, 7, "WordPress")) + + if err := runImportMedia(cmd, []string{"https://example.com/up.zip"}); err != nil { + t.Fatalf("runImportMedia: %v", err) + } + if !prompted { + t.Error("an absent --saveErrorLog must negotiate to \"prompt\" and ask before skipping the report") + } +} diff --git a/cmd/vip-next/commands/import_media_fetch_test.go b/cmd/vip-next/commands/import_media_fetch_test.go new file mode 100644 index 000000000..2d5a5e53f --- /dev/null +++ b/cmd/vip-next/commands/import_media_fetch_test.go @@ -0,0 +1,98 @@ +package commands + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// signedQuery is the shape of the credential the media-import file-errors URL +// carries. The URL is presigned: possession of the query string IS the +// authorisation to download the report. +const signedQuery = "?X-Amz-Signature=deadbeefcafe0123456789abcdef&X-Amz-Credential=AKIAEXAMPLE%2Fus-east-1" + +// TestFetchFailureDetailsHonoursVIPProxy pins the file-errors download to +// vip-next's proxy policy. +// +// Node's fetchFailureDetails (src/lib/media-import/status.ts:296) uses a bare +// node-fetch call, which reads no proxy environment at all, so Node connects +// direct. Go was on http.DefaultTransport, which is not "direct": it honours an +// ambient HTTPS_PROXY without the VIP_USE_SYSTEM_PROXY opt-in while ignoring +// VIP_PROXY. Neither is what the user asked for. Routing it through the shared +// policy makes VIP_PROXY work (a deliberate, reported divergence from Node, +// which cannot download this report from behind a SOCKS-only network at all) +// and makes the un-opted-in HTTPS_PROXY case behave like Node's direct connect. +// +// The stdlib precondition is what stops this passing vacuously: the target is a +// loopback server, and no stdlib resolver ever proxies loopback, so before the +// fix this fetch succeeded. +func TestFetchFailureDetailsHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + clearProxyEnvVars(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopback(t)) + + target := srv.URL + "/errors.json" + signedQuery + assertNoStdlibProxy(t, target) + + if _, err := fetchFailureDetails(target); err == nil { + t.Fatal("fetchFailureDetails succeeded; VIP_PROXY was ignored and the request went direct") + } +} + +// TestFetchFailureDetailsErrorDoesNotLeakThePresignedURL is the privacy half, +// and the reason this call site needed more than a proxy swap. +// +// http.Get returns a *url.Error whose Error() embeds the full request URL, +// signature query string and all. That error is RETURNED, so it reaches +// exit.WithError and, from there, the Go-only cli_error telemetry hook — which +// ships the text to public-api.wordpress.com. A live download credential for a +// customer's media-import error report would have left the machine on every +// failed fetch. This is the same class of leak the proxy-credential redaction +// in internal/httpproxy already closed at its own source. +func TestFetchFailureDetailsErrorDoesNotLeakThePresignedURL(t *testing.T) { + clearProxyEnvVars(t) + + // A closed port: the fetch fails inside net/http, which is where the + // URL-bearing *url.Error is minted. + target := "http://" + closedLoopback(t) + "/errors.json" + signedQuery + + _, err := fetchFailureDetails(target) + if err == nil { + t.Fatal("precondition failed: the fetch should not have succeeded against a closed port") + } + for _, secret := range []string{"X-Amz-Signature", "deadbeefcafe0123456789abcdef", "AKIAEXAMPLE"} { + if strings.Contains(err.Error(), secret) { + t.Errorf("error text carries the presigned credential %q off-box via the cli_error "+ + "telemetry hook:\n\t%s", secret, err.Error()) + } + } + if !strings.Contains(err.Error(), "errors.json") { + t.Errorf("error text lost the path too; it should stay diagnosable:\n\t%s", err.Error()) + } +} + +// TestFetchFailureDetailsParsesTheReport keeps the happy path honest: the +// hardening must not stop a real report being read. +func TestFetchFailureDetailsParsesTheReport(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"fileName":"a.jpg","errors":["boom"]}]`)) + })) + defer srv.Close() + + clearProxyEnvVars(t) + + got, err := fetchFailureDetails(srv.URL + "/errors.json" + signedQuery) + if err != nil { + t.Fatalf("fetchFailureDetails: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d file errors, want 1", len(got)) + } +} diff --git a/cmd/vip-next/commands/import_media_status.go b/cmd/vip-next/commands/import_media_status.go new file mode 100644 index 000000000..3f81ebedc --- /dev/null +++ b/cmd/vip-next/commands/import_media_status.go @@ -0,0 +1,58 @@ +package commands + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/mediaimport" +) + +// ImportMediaStatusCmd returns `vip import media status`. +// +// Node parity: src/bin/vip-import-media-status.js. saveErrorLog defaults +// to "prompt" here (js:55) — unlike the main import command where it +// defaults empty. +func ImportMediaStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check the status of a currently running media import", + Long: "Check the status of a currently running media import or retrieve an error log of the " + + "most recent media import. If the import is still in progress, the command will poll until " + + "the import is complete.", + Args: cobra.NoArgs, + } + cmd.Flags().Bool("exportFileErrorsToJson", false, "Format an error log in JSON. Default is TXT.") + cmd.Flags().StringP("saveErrorLog", "s", "prompt", "Skip the confirmation prompt and download an error log automatically.") + + addAppEnvFlags(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runImportMediaStatus) +} + +func runImportMediaStatus(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + + if !mediaimport.IsSupportedApp(ae.App.Type) { + trackEvent("import_media_command_error", map[string]any{"errorType": "unsupported-app"}) + // vip-import-media-status.js:64 wording. + return errors.New("The type of application you specified does not currently support this feature.") + } + + trackEvent("import_media_check_status_command_execute", nil) + + exportJSON, _ := cmd.Flags().GetBool("exportFileErrorsToJson") + saveErrorLog, _ := cmd.Flags().GetString("saveErrorLog") + + tracker := mediaimport.NewTracker() + tracker.SetPrefix("\n=============================================================\nChecking the Media import status for your environment...\n") + + return mediaImportCheckStatusCmd(cmd, tracker, ae, exportJSON, saveErrorLog) +} diff --git a/cmd/vip-next/commands/import_media_test.go b/cmd/vip-next/commands/import_media_test.go new file mode 100644 index 000000000..0b32d498c --- /dev/null +++ b/cmd/vip-next/commands/import_media_test.go @@ -0,0 +1,370 @@ +package commands + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/appctx" +) + +// mediaStub serves the GraphQL ops + presign + S3 + error-report +// endpoints for the media-import flow. +type mediaStub struct { + mu sync.Mutex + startReq string + abortReq string + startHits atomic.Int32 + abortHits atomic.Int32 + uploadedBody []byte + progressBodies []string + progressHits atomic.Int32 + srvURL string +} + +func (s *mediaStub) start(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + s.srvURL = srv.URL + + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ImportSQLEnvInfo"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[ + {"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true, + "primaryDomain":{"name":"example.com"}, + "importStatus":{"dbOperationInProgress":false,"importInProgress":false}, + "wpSitesSDS":{"nodes":[]}}]}}}`)) + case strings.Contains(bs, `"operationName":"StartMediaImport"`): + s.mu.Lock() + s.startReq = bs + s.mu.Unlock() + s.startHits.Add(1) + _, _ = w.Write([]byte(`{"data":{"startMediaImport":{"applicationId":42,"environmentId":7, + "mediaImportStatus":{"importId":1,"siteId":7,"status":"INITIALIZING"}}}}`)) + case strings.Contains(bs, `"operationName":"AbortMediaImport"`): + s.mu.Lock() + s.abortReq = bs + s.mu.Unlock() + s.abortHits.Add(1) + _, _ = w.Write([]byte(`{"data":{"abortMediaImport":{"applicationId":42,"environmentId":7, + "mediaImportStatusChange":{"importId":1,"siteId":7,"statusFrom":"RUNNING","statusTo":"ABORTING"}}}}`)) + case strings.Contains(bs, `"operationName":"MediaImportProgress"`): + i := int(s.progressHits.Add(1) - 1) + s.mu.Lock() + if i >= len(s.progressBodies) { + i = len(s.progressBodies) - 1 + } + resp := s.progressBodies[i] + s.mu.Unlock() + _, _ = w.Write([]byte(resp)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if strings.Contains(string(body), `"action":"GetObject"`) { + fmt.Fprintf(w, `{"url":"%s/get-me","options":{"method":"GET","headers":{}}}`, s.srvURL) + return + } + fmt.Fprintf(w, `{"url":"%s/s3target","options":{"method":"PUT","headers":{}}}`, s.srvURL) + }) + mux.HandleFunc("/s3target", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.uploadedBody = body + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/file-errors", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"fileName":"a.jpg","errors":["too big"]}]`)) + }) + return srv +} + +// importCtxWithType is importCtx with an explicit app type — the media +// commands gate on App.Type (media-file-import.ts:18). +func importCtxWithType(appID, envID int64, appType string) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "parityapp", Type: appType, TypeId: 2}, + Env: appctx.Env{ID: envID, Name: "develop", Type: "develop"}, + }) +} + +func mediaProgress(status string, processed, total int, extra string) string { + return fmt.Sprintf(`{"data":{"app":{"environments":[{"id":7,"name":"develop","type":"develop","repo":"r", + "mediaImportStatus":{"importId":1,"siteId":7,"status":"%s","filesTotal":%d,"filesProcessed":%d%s}}]}}}`, + status, total, processed, extra) +} + +func setupMediaTest(t *testing.T, stub *mediaStub) { + t.Helper() + srv := stub.start(t) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, + Token: "tok", + }) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_IMPORT_MEDIA_INTERVAL_MS", "1") +} + +func TestImportMediaInvalidLocalArchiveExitsZero(t *testing.T) { + stub := &mediaStub{} + setupMediaTest(t, stub) + + cmd := ImportMediaCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runImportMedia(cmd, []string{"./dump.sql"}); err != nil { + t.Fatalf("invalid archive must exit 0, got %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Invalid local archive provided: ./dump.sql") || + !strings.Contains(out, ".tar.gz, .tgz, .zip") { + t.Errorf("stdout = %q", out) + } + if stub.startHits.Load() != 0 { + t.Error("StartMediaImport must not fire") + } +} + +func TestImportMediaURLHappyPath(t *testing.T) { + stub := &mediaStub{progressBodies: []string{ + mediaProgress("RUNNING", 5, 10, ""), + mediaProgress("COMPLETED", 10, 10, ""), + }} + setupMediaTest(t, stub) + + cmd := ImportMediaCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runImportMedia(cmd, []string{"https://example.com/up.zip"}); err != nil { + t.Fatalf("runImportMedia: %v\nstdout: %s", err, stdout.String()) + } + if !strings.Contains(stdout.String(), "Importing archive from: https://example.com/up.zip") { + t.Errorf("banner missing: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "to: example.com (develop)") { + t.Errorf("domain line missing: %q", stdout.String()) + } + if stub.startHits.Load() != 1 { + t.Fatalf("StartMediaImport hits = %d", stub.startHits.Load()) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if !strings.Contains(stub.startReq, `"archiveUrl":"https://example.com/up.zip"`) || + !strings.Contains(stub.startReq, `"apiVersion":"v2"`) { + t.Errorf("start input = %s", stub.startReq) + } +} + +func TestImportMediaLocalArchiveUploadsAndUsesGetObjectURL(t *testing.T) { + stub := &mediaStub{progressBodies: []string{mediaProgress("COMPLETED", 1, 1, "")}} + setupMediaTest(t, stub) + + dir := t.TempDir() + archive := filepath.Join(dir, "uploads.zip") + content := []byte("PK\x03\x04 fake zip payload") + if err := os.WriteFile(archive, content, 0o600); err != nil { + t.Fatal(err) + } + + cmd := ImportMediaCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runImportMedia(cmd, []string{archive}); err != nil { + t.Fatalf("runImportMedia: %v\nstdout: %s", err, stdout.String()) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if string(stub.uploadedBody) != string(content) { + t.Errorf("uploaded body = %q", stub.uploadedBody) + } + if !strings.Contains(stub.startReq, `"archiveUrl":"`+stub.srvURL+`/get-me"`) { + t.Errorf("start input must use the GetObject URL: %s", stub.startReq) + } + if !strings.Contains(stdout.String(), "Importing local archive: "+archive+" (uploaded to temporary URL)") { + t.Errorf("banner missing: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "Upload progress: 100%") { + t.Errorf("upload progress missing: %q", stdout.String()) + } +} + +func TestImportMediaFailedImportExitsOne(t *testing.T) { + stub := &mediaStub{progressBodies: []string{ + mediaProgress("FAILED", 3, 10, + `,"failureDetails":{"previousStatus":"RUNNING","globalErrors":["disk full"],"fileErrorsUrl":null}`), + }} + setupMediaTest(t, stub) + + cmd := ImportMediaCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + err := runImportMedia(cmd, []string{"https://example.com/up.zip"}) + if err == nil || !strings.Contains(err.Error(), "Import failed at status:") || + !strings.Contains(err.Error(), "RUNNING") || !strings.Contains(err.Error(), "disk full") { + t.Errorf("err = %v", err) + } +} + +func TestImportMediaGraphQLErrorOnStartExitsZero(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ImportSQLEnvInfo"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[ + {"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true, + "primaryDomain":{"name":"example.com"}, + "importStatus":{"dbOperationInProgress":false,"importInProgress":false}, + "wpSitesSDS":{"nodes":[]}}]}}}`)) + case strings.Contains(bs, `"operationName":"StartMediaImport"`): + _, _ = w.Write([]byte(`{"data":null,"errors":[{"message":"another import is running"}]}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + + cmd := ImportMediaCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runImportMedia(cmd, []string{"https://example.com/up.zip"}); err != nil { + t.Fatalf("GraphQL start error must exit 0, got %v", err) + } + if !strings.Contains(stdout.String(), "Error:") || + !strings.Contains(stdout.String(), "another import is running") { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestMediaErrorLogDownload(t *testing.T) { + stub := &mediaStub{} + srv := stub.start(t) + stub.mu.Lock() + stub.progressBodies = []string{mediaProgress("COMPLETED", 8, 10, + `,"failureDetails":{"previousStatus":null,"globalErrors":[],"fileErrorsUrl":"`+srv.URL+`/file-errors"}`)} + stub.mu.Unlock() + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_IMPORT_MEDIA_INTERVAL_MS", "1") + + // error-log file is written to CWD; isolate it. + oldWD, _ := os.Getwd() + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(oldWD) }() + + cmd := ImportMediaStatusCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtxWithType(42, 7, "WordPress")) + _ = cmd.Flags().Set("saveErrorLog", "true") + _ = cmd.Flags().Set("exportFileErrorsToJson", "false") + + if err := runImportMediaStatus(cmd, nil); err != nil { + t.Fatalf("runImportMediaStatus: %v", err) + } + matches, _ := filepath.Glob(filepath.Join(tmp, "media-import-parityapp-*.txt")) + if len(matches) != 1 { + t.Fatalf("expected one exported error log, got %v", matches) + } + content, _ := os.ReadFile(matches[0]) + if !strings.Contains(string(content), "File Name: a.jpg") || !strings.Contains(string(content), "too big") { + t.Errorf("error log = %q", content) + } +} + +func TestImportMediaStatusUnsupportedApp(t *testing.T) { + cmd := ImportMediaStatusCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtxWithType(42, 7, "node")) + + err := runImportMediaStatus(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "does not currently support this feature.") { + t.Errorf("err = %v", err) + } +} + +func TestImportMediaAbortUnsupportedApp(t *testing.T) { + cmd := ImportMediaAbortCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtxWithType(42, 7, "node")) + + err := runImportMediaAbort(cmd, nil) + // abort wording differs from status (vip-import-media-abort.js:78) + if err == nil || !strings.Contains(err.Error(), "does not currently support media file imports.") { + t.Errorf("err = %v", err) + } +} + +func TestImportMediaAbortHappyPath(t *testing.T) { + stub := &mediaStub{progressBodies: []string{ + mediaProgress("ABORTING", 3, 10, ""), + mediaProgress("ABORTED", 3, 10, ""), + }} + setupMediaTest(t, stub) + + cmd := ImportMediaAbortCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtxWithType(42, 7, "WordPress")) + + if err := runImportMediaAbort(cmd, nil); err != nil { + t.Fatalf("runImportMediaAbort: %v", err) + } + if stub.abortHits.Load() != 1 { + t.Errorf("AbortMediaImport hits = %d", stub.abortHits.Load()) + } +} diff --git a/cmd/vip-next/commands/import_sql.go b/cmd/vip-next/commands/import_sql.go new file mode 100644 index 000000000..8c448efd6 --- /dev/null +++ b/cmd/vip-next/commands/import_sql.go @@ -0,0 +1,1153 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/fatih/color" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/searchreplace" + "github.com/Automattic/vip/internal/siteimport" + "github.com/Automattic/vip/internal/sqlvalidation" + "github.com/Automattic/vip/internal/tui" + "github.com/Automattic/vip/internal/upload" +) + +// ImportSQLCmd returns `vip import sql <file|url>`. +// +// Node parity: src/bin/vip-import-sql.js (852 LOC). Flow: gates → +// (local) SQL validation → playbook → type-the-domain confirm → +// optional skip-backup double confirm → optional in-place confirm → +// progress phase (search-replace, upload / URL passthrough, +// StartImport mutation) → status polling (internal/siteimport). +func ImportSQLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sql <file|url>", + Short: "Import a SQL database file into an environment", + Long: "Import a local or remote SQL database file into a VIP Platform environment. " + + "Local files are validated and uploaded; remote files are fetched by the platform. " + + "The command polls the import status until completion.", + Args: cobra.ExactArgs(1), + } + + // Flags — Node .option() registrations, vip-import-sql.js:546-575. + cmd.Flags().BoolP("skip-validate", "s", false, "Do not perform file validation prior to import. If the file contains unsupported entries, the import is likely to fail.") + cmd.Flags().StringArray("search-replace", nil, "Search for a string in a local or remote SQL database file and replace it with a new string. Separate the values by a comma only; no spaces (e.g. --search-replace=\"from,to\"). Can be passed more than once.") + cmd.Flags().BoolP("in-place", "i", false, "Overwrite a local SQL database file with the results of a search and replace operation prior to import.") + cmd.Flags().StringP("output", "o", "", "Save the results of a --search-replace operation that is run against a local SQL database file to a copy of that file. Accepts a local file path. Ignored when used with the --in-place option.") + cmd.Flags().Bool("skip-maintenance-mode", false, "Prevent an unlaunched environment from going into maintenance mode during the import of a local or remote SQL database file. Skipping maintenance mode can cause site instability during import.") + cmd.Flags().StringP("md5", "m", "", "Verify the integrity of a remote SQL database file. Accepts an MD5 hash value.") + cmd.Flags().StringArray("header", nil, "Pass a header name and value (Formatted as \"Name: Value\") in a request for a remote SQL database file. Can be passed more than once for multiple headers and values.") + cmd.Flags().BoolP("skip-backup", "B", false, "Skip creating a backup before importing the SQL file. WARNING: This is extremely dangerous and can result in permanent data loss.") + + addAppEnvFlags(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runImportSQL) +} + +// Prompt indirection so command-level tests can stub the interactive +// pieces without a TTY. +var ( + importInputPrompt = appctx.Input + importConfirmPrompt = appctx.Confirm +) + +// importHeader mirrors the parsed {name, value} pairs Node builds in +// parseHeaders (vip-import-sql.js:119). +type importHeader struct { + Name string + Value string +} + +// parseImportHeaders ports parseHeaders (vip-import-sql.js:119). +func parseImportHeaders(headers []string) ([]importHeader, error) { + parsed := make([]importHeader, 0, len(headers)) + for _, header := range headers { + colonIndex := strings.Index(header, ":") + if colonIndex == -1 { + return nil, fmt.Errorf("Invalid header format: %q. Expected format: \"Name: Value\"", header) + } + name := strings.TrimSpace(header[:colonIndex]) + value := strings.TrimSpace(header[colonIndex+1:]) + if name == "" { + return nil, fmt.Errorf("Invalid header format: %q. Header name cannot be empty.", header) + } + parsed = append(parsed, importHeader{Name: name, Value: value}) + } + return parsed, nil +} + +// driveLetterProtocolRE — Node isValidUrl rejects single-drive-letter +// "protocols" so Windows paths like C:\x don't count as URLs +// (vip-import-sql.js:99). +var driveLetterProtocolRE = regexp.MustCompile(`(?i)^[a-z]:$`) + +// isValidImportURL ports isValidUrl (vip-import-sql.js:96). +func isValidImportURL(s string) bool { + u, err := url.Parse(s) + if err != nil || u.Scheme == "" { + return false + } + return !driveLetterProtocolRE.MatchString(u.Scheme + ":") +} + +// isValidMd5 ports isValidMd5 (vip-import-sql.js:110). +var md5RE = regexp.MustCompile(`(?i)^[a-f0-9]{32}$`) + +func isValidMd5(md5 string) bool { return md5RE.MatchString(md5) } + +// importEnvInfo flattens the ImportSQLEnvInfo response fields the +// handler consumes (Node got these via appQuery — vip-import-sql.js:41). +type importEnvInfo struct { + Launched bool + PrimaryDomainName string + HasImportStatus bool + ImportInProgress bool + DbOperationInProgress bool + WPSites []importWPSite + // WPSitesKnown is false when the API returned no site catalog at all + // (wpSitesSDS null, or wpSitesSDS.nodes null) — Node's `siteArray` + // being undefined. It is true for a present list, INCLUDING an empty + // one. The playbook treats the two cases differently. + WPSitesKnown bool +} + +type importWPSite struct { + ID int64 + HomeURL string +} + +func fetchImportEnvInfo(ctx context.Context, client graphql.Client, appID, envID int64) (*importEnvInfo, error) { + resp, err := gql.ImportSQLEnvInfo(ctx, client, appID, envID) + if err != nil { + return nil, err + } + info := &importEnvInfo{} + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + return info, nil + } + env := resp.App.Environments[0] + if env.Launched != nil { + info.Launched = *env.Launched + } + if env.PrimaryDomain != nil { + info.PrimaryDomainName = env.PrimaryDomain.Name + } + if env.ImportStatus != nil { + info.HasImportStatus = true + if env.ImportStatus.ImportInProgress != nil { + info.ImportInProgress = *env.ImportStatus.ImportInProgress + } + if env.ImportStatus.DbOperationInProgress != nil { + info.DbOperationInProgress = *env.ImportStatus.DbOperationInProgress + } + } + // Node reads `selectedEnvironmentObj?.wpSitesSDS?.nodes`, so BOTH a null + // wpSitesSDS and a null nodes leave siteArray undefined. An empty array + // is a real (fatal) answer; undefined is "don't know" (warn + proceed). + if env.WpSitesSDS != nil && env.WpSitesSDS.Nodes != nil { + info.WPSitesKnown = true + for _, n := range env.WpSitesSDS.Nodes { + if n == nil { + continue + } + s := importWPSite{} + if n.Id != nil { + s.ID = *n.Id + } + if n.HomeUrl != nil { + s.HomeURL = *n.HomeUrl + } + info.WPSites = append(info.WPSites, s) + } + } + return info, nil +} + +// isMultiSiteInSiteMeta ports is-multi-site.ts:11 (sans Node's WeakMap +// memo — one call per process here). +func isMultiSiteInSiteMeta(ctx context.Context, client graphql.Client, appID, envID int64) (bool, error) { + resp, err := gql.AppMultiSiteCheck(ctx, client, &appID, &envID) + if err != nil { + // Node: exit.withError(`StartImport call failed: ${GraphQlError}`) + // — is-multi-site.ts:56 (message is a Node copy/paste bug; kept). + return false, fmt.Errorf("StartImport call failed: %s", err) + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + return false, nil + } + env := resp.App.Environments[0] + if (env.IsMultisite != nil && *env.IsMultisite) || + (env.IsSubdirectoryMultisite != nil && *env.IsSubdirectoryMultisite) { + return true, nil + } + return false, nil +} + +// isMultisitePrimaryDomainMapped ports is-multisite-domain-mapped.ts:72. +func isMultisitePrimaryDomainMapped(ctx context.Context, client graphql.Client, appID, envID int64, primaryDomain string) (bool, error) { + resp, err := gql.AppMappedDomains(ctx, client, &appID, &envID) + if err != nil { + // Node: same copy/paste "StartImport call failed" message + // (is-multisite-domain-mapped.ts:111). + return false, fmt.Errorf("StartImport call failed: %s", err) + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + return false, nil + } + env := resp.App.Environments[0] + if env.Domains == nil { + return false, nil + } + for _, d := range env.Domains.Nodes { + if d != nil && d.Name == primaryDomain { + return true, nil + } + } + return false, nil +} + +// gateInput carries everything importSQLGates needs; limits are +// injectable so tests don't need 10GB fixtures. +type gateInput struct { + FileNameOrURL string + IsURL bool + Md5 string + Launched bool + AppTypeID int64 + Info *importEnvInfo + Out io.Writer + SizeLimit int64 + SizeLimitLaunched int64 +} + +// importSQLGates ports gates (vip-import-sql.js:154). Every error string +// is verbatim Node. +func importSQLGates(g gateInput) error { + if g.SizeLimit == 0 { + g.SizeLimit = siteimport.SQLImportFileSizeLimit + } + if g.SizeLimitLaunched == 0 { + g.SizeLimitLaunched = siteimport.SQLImportFileSizeLimitLaunched + } + + if g.Md5 != "" && !isValidMd5(g.Md5) { + trackEvent("import_sql_command_error", map[string]any{"error_type": "invalid-md5"}) + return errors.New("The provided MD5 hash is invalid. It should be a 32-character hexadecimal string.") + } + if !g.IsURL && g.Md5 != "" { + fmt.Fprintln(g.Out, color.YellowString("The --md5 parameter is only valid for imports from a remote URL. This option will be ignored.")) + } + + if !g.IsURL { + fileName := g.FileNameOrURL + meta, metaErr := upload.GetFileMeta(fileName) + if metaErr != nil { + // Node's gates call getFileMeta first (js:175), so a missing + // file errors before any filename validation. Node surfaces a + // raw ENOENT; we use the gate's own unreadable wording since + // the raw errno text is platform-specific anyway. + trackEvent("import_sql_command_error", map[string]any{"error_type": "sqlfile-unreadable"}) + return fmt.Errorf("File '%s' does not exist or is not readable.", fileName) + } + + if err := sqlvalidation.ValidateFilename(meta.BaseName); err != nil { + trackEvent("import_sql_command_error", map[string]any{"error_type": "invalid-filename"}) + return err + } + if err := sqlvalidation.ValidateImportFileExtension(fileName); err != nil { + trackEvent("import_sql_command_error", map[string]any{"error_type": "invalid-extension"}) + return err + } + + fi, statErr := os.Stat(fileName) + if statErr != nil { + trackEvent("import_sql_command_error", map[string]any{"error_type": "sqlfile-unreadable"}) + return fmt.Errorf("File '%s' does not exist or is not readable.", fileName) + } + if fi.IsDir() { + trackEvent("import_sql_command_error", map[string]any{"error_type": "sqlfile-notfile"}) + return fmt.Errorf("Path '%s' is not a file.", fileName) + } + if f, err := os.Open(fileName); err != nil { // #nosec G304 -- access check, Node checkFileAccess + trackEvent("import_sql_command_error", map[string]any{"error_type": "sqlfile-unreadable"}) + return fmt.Errorf("File '%s' does not exist or is not readable.", fileName) + } else { + f.Close() + } + if fi.Size() == 0 { + trackEvent("import_sql_command_error", map[string]any{"error_type": "sqlfile-empty"}) + return fmt.Errorf("File '%s' is empty.", fileName) + } + + maxFileSize := g.SizeLimit + if g.Launched { + maxFileSize = g.SizeLimitLaunched + } + if fi.Size() > maxFileSize { + trackEvent("import_sql_command_error", map[string]any{ + "error_type": "sqlfile-toobig", "file_size": fi.Size(), "launched": g.Launched, + }) + launchedNote := "" + if g.Launched { + launchedNote = " Note: This limit is lower for launched environments to maintain site stability." + } + return fmt.Errorf("The sql import file size (%d bytes) exceeds the limit (%d bytes).%s\n\nPlease split it into multiple files or contact support for assistance.", + fi.Size(), maxFileSize, launchedNote) + } + } + + // currentUserCanImportForApp is a stub in Node (db-file-import.ts:21, + // always true) — no Go branch needed. + + if !siteimport.IsSupportedApp(g.AppTypeID) { + trackEvent("import_sql_command_error", map[string]any{"error_type": "unsupported-app"}) + return errors.New("The type of application you specified does not currently support SQL imports.") + } + + if g.Info == nil || !g.Info.HasImportStatus { + trackEvent("import_sql_command_error", map[string]any{"error_type": "empty-import-status"}) + return errors.New("Could not determine the import status for this environment. Check the app/environment and if the problem persists, contact support for assistance.") + } + if g.Info.ImportInProgress { + trackEvent("import_sql_command_error", map[string]any{"error_type": "existing-import"}) + return errors.New("There is already an import in progress.\n\nYou can view the status with command:\n vip import sql status") + } + if g.Info.DbOperationInProgress { + trackEvent("import_sql_command_error", map[string]any{"error_type": "existing-dbop"}) + return errors.New("There is already a database operation in progress. Please try again later.") + } + return nil +} + +// validateAndGetTableNames ports validateAndGetTableNames +// (vip-import-sql.js:420): run the static SQL checks + the site-type +// (multisite) checks, returning the captured table names. The "Reading +// line N " ticker (sql.ts:533) prints every 500 lines. +func validateAndGetTableNames(cmd *cobra.Command, client graphql.Client, appID, envID int64, fileName string, skipValidate bool, searchReplace []string, isMultiSite bool) ([]string, error) { + out := cmd.OutOrStdout() + if skipValidate { + fmt.Fprintln(out, "Skipping SQL file validation.") + return []string{}, nil + } + + f, err := os.Open(fileName) // #nosec G304 -- user-supplied CLI path + if err != nil { + // line-by-line.ts:29 wording. + return nil, errors.New("The file at the provided path is either missing or not readable. Please check the input and try again.") + } + defer f.Close() + + // Static checks + multisite capture share one streaming pass, like + // Node's fileLineValidations dispatch loop (line-by-line.ts:51). + wpSiteCapture := siteimport.NewMultilineCapture("INSERT INTO `wp_site`") + var wpSiteStatements [][]string + ticker := newImportLineTicker(out, isTerminalWriter(out)) + res, scanErr := sqlvalidation.ValidateWithLineHook(f, func(line string, lineNum int) { + if lineNum%500 == 0 { + ticker.tick(lineNum) // sql.ts:533 trailing space + } + wpSiteStatements = wpSiteCapture.Feed(line) + }) + ticker.done() + if scanErr != nil { + return nil, fmt.Errorf("Error validating input file: %s", scanErr) + } + isMultiSiteSqlDump := res.IsMultiSite + + // Static-validation report (import mode): problems throw with the + // joined error output + --skip-validate advice (vip-import-sql.js:436). + if msg, problems := buildImportValidationError(res); problems > 0 { + fmt.Fprintln(out, "") + return nil, fmt.Errorf("%s\n\nIf you are confident that the file does not contain unsupported statements, you can retry the command with the %s option.\n", + msg, color.YellowString("--skip-validate")) + } + + // siteTypeValidations.postLineExecutionProcessing (site-type.ts:29). + if !isMultiSite && isMultiSiteSqlDump { + trackEvent("import_sql_command_error", map[string]any{"error_type": "not-multisite-with-multisite-sql-dump"}) + return nil, errors.New("You have provided a multisite SQL dump file for import into a single site (non-multisite).") + } + if isMultiSite && !isMultiSiteSqlDump { + trackEvent("import_sql_command_error", map[string]any{"error_type": "subsite-import-without-subsite-sql-dump"}) + return nil, errors.New("You have requested a subsite SQL import but have not provided a subsite compatible SQL dump.") + } + + primaryDomain := siteimport.MaybeSearchReplacePrimaryDomain( + siteimport.GetPrimaryDomainFromSQL(wpSiteStatements), searchReplace) + if primaryDomain != "" { + mapped, err := isMultisitePrimaryDomainMapped(cmd.Context(), client, appID, envID, primaryDomain) + if err != nil { + return nil, err + } + if isMultiSite && !mapped { + trackEvent("import_sql_command_error", map[string]any{"error_type": "multisite-import-where-primary-domain-unmapped"}) + return nil, errors.New("This import would set the network's main site domain to " + primaryDomain + + ", however this domain is not mapped to the target environment. Please replace this domain in your " + + "import file, or map it to the environment.") + } + } + + return res.TableNames, nil +} + +// isTerminalWriter reports whether w is backed by a terminal. Sensing the +// WRITER (rather than os.Stdout unconditionally) keeps redirected output and +// tests honest. +func isTerminalWriter(w io.Writer) bool { + f, ok := w.(interface{ Fd() uintptr }) + return ok && term.IsTerminal(int(f.Fd())) +} + +// importLineTicker renders the "Reading line N" validation counter. +// +// Node's sql.ts imports `{ stdout as log } from '@wwa/single-line-log'` and +// calls log(`Reading line ${lineNum} `) every 500 lines (sql.ts:531-534), so +// the counter REWRITES itself in place. Printing a newline-terminated line +// per tick instead means a 5M-line dump buries the validation findings under +// ~10,000 lines of progress chrome. +// +// tui.MultiLineRenderer is the repo's in-place renderer (the same primitive +// startImportProgressRenderer uses); a one-element frame is the single-line +// case. On a non-TTY sink the ticker goes silent: Node still emits its +// cursor-movement escapes there, but those carry no information once the +// output is a pipe or a CI log — and emitting raw ANSI (or the old +// 10,000 lines) is strictly worse than nothing. This matches the non-TTY +// handling in startImportProgressRenderer (progress_renderer.go:36). +type importLineTicker struct { + r *tui.MultiLineRenderer +} + +func newImportLineTicker(w io.Writer, tty bool) *importLineTicker { + if !tty { + return &importLineTicker{} + } + return &importLineTicker{r: tui.NewMultiLineRenderer(w, true)} +} + +func (t *importLineTicker) tick(lineNum int) { + if t.r == nil { + return + } + // Trailing space is Node's (sql.ts:533). + t.r.Render([]string{fmt.Sprintf("Reading line %d ", lineNum)}) +} + +// done releases the line so subsequent output starts fresh instead of +// overwriting the last counter frame. +func (t *importLineTicker) done() { + if t.r == nil { + return + } + t.r.Done() +} + +// importSearchReplacePair is one parsed --search-replace value. +// HasTo distinguishes "no replacement given at all" from "replace with the +// empty string" — two different wire payloads with two different server +// behaviors. +type importSearchReplacePair struct { + From string + To string + HasTo bool +} + +// parseImportSearchReplacePair ports Node's +// `pair.split( ',' ).map( str => str.trim() )` + `{from: arr[0], to: arr[1]}` +// (vip-import-sql.js:821-827; the identical destructure lives in +// formatSearchReplaceValues, format.ts:201). +// +// Two JS behaviors that a naive strings.SplitN(pair, ",", 2) gets wrong: +// +// - split(',') has no limit, so "a,b,c" → ["a","b","c"] and only the first +// two entries are read. The tail is DISCARDED, not appended to `to`. +// - with no comma, arr[1] is undefined, and JSON.stringify omits undefined +// properties — so `to` never reaches the server. +func parseImportSearchReplacePair(pair string) importSearchReplacePair { + parts := strings.Split(pair, ",") + out := importSearchReplacePair{From: strings.TrimSpace(parts[0])} + if len(parts) > 1 { + out.To = strings.TrimSpace(parts[1]) + out.HasTo = true + } + return out +} + +// localSearchReplaceNeeded reports whether the local search-replace pass will +// produce anything anyone can observe. +// +// DELIBERATE DIVERGENCE FROM NODE — cost only, no change to imported bytes. +// Node runs the pass for every local file with pairs and then discards the +// result: `outputFileName` is destructured at vip-import-sql.js:674, +// type-checked at :681, and never referenced again, because `fileNameToUpload` +// was pinned to the ORIGINAL at :577. Validation does not use it either (it +// takes the original plus the raw pairs and simulates the replacement). The +// server performs the real replacement from the StartImport payload. +// +// So on the default path Node reads the whole dump and writes a rewritten copy +// to a temp file that nothing ever opens — on a 5 GB dump, 5 GB read and 5 GB +// written for nothing. We run it only when the output is observable: +// - --in-place, which rewrites the user's own file and is the only thing that +// changes what gets uploaded, or +// - an explicit --output, which the user asked for as an artifact. +// +// Note --output is NOT the file that gets imported, in either CLI; only +// --in-place changes that. See TestImportSQLHappyPathUploadsOriginalFile. +func localSearchReplaceNeeded(isURL, inPlace bool, output string, searchReplace []string) bool { + if isURL || len(searchReplace) == 0 { + return false // Node never runs a local pass for a URL + } + return inPlace || output != "" +} + +// serverSideSearchReplaceNeeded reports whether --search-replace pairs must be +// sent to the server, i.e. whether the bytes we uploaded still need replacing. +// +// DELIBERATE DIVERGENCE FROM NODE. Node applies the pairs twice on the +// --in-place path, which silently corrupts non-idempotent replacements: +// +// - vip-import-sql.js:577 sets `fileNameToUpload = fileNameOrURL` BEFORE the +// search-replace block at :671 and never reassigns it, so the rewritten +// --output copy is discarded and the ORIGINAL is uploaded. The server pass +// at :760 is then the only application. Correct. +// - With --in-place the original file on disk IS the rewritten file, so the +// upload already carries the replacements — and :760 is not gated on +// isUrl, so the server applies them again. +// +// A domain swap is idempotent and hides this (the second pass matches +// nothing), but a pair like `a,aa` turns "a" into "aaaa". We send the pairs +// only when the upload has not already been rewritten. +func serverSideSearchReplaceNeeded(isURL, inPlace bool, searchReplace []string) bool { + if len(searchReplace) == 0 { + return false + } + // A URL is never rewritten locally (runImportSQL forces inPlace=false for + // URLs), so the server must always do the work. + if isURL { + return true + } + // Local file: only --in-place mutates what we upload. + return !inPlace +} + +// buildImportSearchReplaceInput maps the raw --search-replace values onto +// the StartImport input shape. A pair with no comma leaves To nil so the +// field is omitted from the JSON body, matching Node. Sending to:"" instead +// would tell the server to replace every occurrence of `from` with nothing. +func buildImportSearchReplaceInput(searchReplace []string) []*gql.AppEnvironmentImportSearchReplace { + pairs := make([]*gql.AppEnvironmentImportSearchReplace, 0, len(searchReplace)) + for _, raw := range searchReplace { + p := parseImportSearchReplacePair(raw) + from := p.From + entry := &gql.AppEnvironmentImportSearchReplace{From: &from} + if p.HasTo { + to := p.To + entry.To = &to + } + pairs = append(pairs, entry) + } + return pairs +} + +// displayPlaybook ports displayPlaybook (vip-import-sql.js:446). +func displayPlaybook(out io.Writer, fileName, domain, formattedEnv string, app appctx.App, launched, isMultiSite bool, tableNames, searchReplace []string, wpSites []importWPSite, wpSitesKnown bool) error { + fmt.Fprintln(out) + fmt.Fprintf(out, " importing: %s\n", color.HiBlueString(fileName)) + fmt.Fprintf(out, " to: %s\n", color.CyanString(domain)) + fmt.Fprintf(out, " site: %s (%s)\n", app.Name, formattedEnv) + + // Node's formatSearchReplaceValues (format.ts:201) destructures the same + // `split(',').map(trim)` as the wire payload, so the playbook must show + // the same from/to the server will receive. + for _, pair := range searchReplace { + p := parseImportSearchReplacePair(pair) + fmt.Fprintf(out, " s-r: %s -> %s\n", color.BlueString(p.From), color.BlueString(p.To)) + } + + if isMultiSite { + fmt.Fprintf(out, " multisite: true\n") + } + + if len(tableNames) == 0 { + return nil // validation skipped — no playbook table info (js:481) + } + fmt.Fprintln(out) + if !isMultiSite { + fmt.Fprintln(out, "Tables that will be imported by this process:") + fmt.Fprintln(out, strings.Join(tableNames, " ")) + return nil + } + + // Node's three-way branch (js:489-499). `siteArray` is + // `selectedEnvironmentObj?.wpSitesSDS?.nodes`: + // + // undefined (wpSitesSDS or nodes null) → yellow warning, then RETURN + // (the import proceeds) + // [] → throw + // [...] → per-site table breakdown + // + // Proceeding on "unknown" is not unguarded: promptToContinueImport still + // makes the user type the target domain immediately after the playbook. + // Hard-failing here would make SQL import impossible on any multisite + // whose site catalog the API declines to return. + if !wpSitesKnown { + fmt.Fprintln(out, color.YellowString( + "Unable to determine the network sites affected by this import. Please proceed only if you are confident that the contents of the file are valid for import.")) + return nil + } + if len(wpSites) == 0 { + return errors.New("There were no sites in your multisite installation.") + } + + if launched { + fmt.Fprintln(out, color.YellowString("You are updating tables in a launched multisite environment. The performance of sites on the network might be impacted by this operation.")) + } + fmt.Fprintln(out, color.YellowString("The following sites will be affected by the import:")) + for _, site := range wpSites { + var siteRE *regexp.Regexp + if site.ID == 1 { + siteRE = regexp.MustCompile(`(?i)^wp_[a-z]+`) + } else { + siteRE = regexp.MustCompile(fmt.Sprintf(`(?i)^wp_%d_[a-z]+`, site.ID)) + } + var group []string + for _, name := range tableNames { + if siteRE.MatchString(name) { + group = append(group, name) + } + } + fmt.Fprintln(out) + fmt.Fprintln(out, color.HiBlueString( + fmt.Sprintf("Blog with ID %d and URL %s will import the following tables:", site.ID, site.HomeURL))) + fmt.Fprintln(out, strings.Join(group, " ")) + } + return nil +} + +// promptToContinueImport ports promptToContinue (vip-import-sql.js:326): +// the user must type the (uppercased) domain to proceed. +func promptToContinueImport(cmd *cobra.Command, out io.Writer, launched bool, formattedEnv, domain string, isMultiSite bool, tableNames []string) error { + fmt.Fprintln(out) + promptToMatch := strings.ToUpper(domain) + source := "the above file" + if !isMultiSite && len(tableNames) > 0 { + source = "the above tables" + } + launchedLabel := "unlaunched" + if launched { + launchedLabel = "launched" + } + message := fmt.Sprintf("You are about to import %s into the %s %s environment %s.\nType '%s' (without the quotes) to continue:\n", + source, launchedLabel, formattedEnv, color.YellowString(domain), color.YellowString(promptToMatch)) + answer, err := importInputPrompt(cmd, message, "") + if err != nil || strings.ToUpper(answer) != promptToMatch { + trackEvent("import_sql_unexpected_tables", nil) + return errors.New("The input did not match the expected environment label. Import aborted.") + } + return nil +} + +// confirmSkipBackup ports confirmSkipBackup (vip-import-sql.js:359): the +// ⚠️ warning wall, a y/n confirm, then a typed "yes". +func confirmSkipBackup(cmd *cobra.Command, out io.Writer) (bool, error) { + fmt.Fprintln(out, color.New(color.FgRed, color.Bold).Sprint("⚠️ WARNING ⚠️")) + fmt.Fprintln(out, color.RedString(color.New(color.FgRed, color.Bold).Sprint("YOU ARE ABOUT TO SKIP CREATING A BACKUP BEFORE IMPORTING SQL!\n"))) + fmt.Fprintln(out, color.New(color.FgYellow, color.Bold).Sprint("This action is EXTREMELY DANGEROUS and can result in:")) + fmt.Fprintln(out, color.New(color.FgYellow, color.Bold).Sprint("• Permanent data loss")) + fmt.Fprintln(out, color.New(color.FgYellow, color.Bold).Sprint("• Inability to automatically restore your database")) + fmt.Fprintln(out, color.New(color.FgYellow, color.Bold).Sprint("• Complete site failure")) + fmt.Fprintln(out, color.New(color.FgRed, color.Bold).Sprint("There is NO way to undo this action once the import begins!\n")) + + importAbortedMsg := color.RedString("✗ Import aborted.") + + first, err := importConfirmPrompt(cmd, "Are you absolutely certain you want to skip the backup?", false) + if err != nil || !first { + trackEvent("import_sql_skip_backup_cancelled", nil) + fmt.Fprintln(out, importAbortedMsg) + return false, nil + } + + second, err := importInputPrompt(cmd, + fmt.Sprintf("Type '%s' (without quotes) to proceed WITHOUT creating a backup (this cannot be undone):\n", color.YellowString("yes")), "") + if err != nil || strings.ToLower(second) != "yes" { + trackEvent("import_sql_skip_backup_cancelled", nil) + fmt.Fprintln(cmd.ErrOrStderr(), "Failed to confirm!") + fmt.Fprintln(out, importAbortedMsg) + return false, nil + } + + trackEvent("import_sql_skip_backup_confirmed", nil) + fmt.Fprintln(out, color.RedString("⚠️ Backup will be skipped. Proceeding with import...")) + return true, nil +} + +func runImportSQL(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + fileNameOrURL := args[0] + + skipValidate, _ := cmd.Flags().GetBool("skip-validate") + searchReplace, _ := cmd.Flags().GetStringArray("search-replace") + inPlace, _ := cmd.Flags().GetBool("in-place") + output, _ := cmd.Flags().GetString("output") + skipMaintenanceMode, _ := cmd.Flags().GetBool("skip-maintenance-mode") + md5Flag, _ := cmd.Flags().GetString("md5") + headerFlags, _ := cmd.Flags().GetStringArray("header") + skipBackup, _ := cmd.Flags().GetBool("skip-backup") + + gqlCtx := gql.WithAllowGQLErrors(cmd.Context()) + info, err := fetchImportEnvInfo(gqlCtx, cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + isMultiSite, err := isMultiSiteInSiteMeta(gqlCtx, cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + isURL := isValidImportURL(fileNameOrURL) + + headers, err := parseImportHeaders(headerFlags) + if err != nil { + return err + } + if !isURL && len(headers) > 0 { + fmt.Fprintln(out, color.YellowString("The --header option is only valid for imports from a remote URL. This option will be ignored.")) + } + if isURL && inPlace { + // Node's wording says "remote URL" here — known Node copy bug, + // kept bug-for-bug (vip-import-sql.js:600). + fmt.Fprintln(out, color.YellowString("The --in-place option is only valid for imports from a remote URL. This option will be ignored.")) + inPlace = false + } + if isURL && output != "" { + fmt.Fprintln(out, color.YellowString("The --output option is only valid for imports of a local file. This option will be ignored.")) + output = "" + } + + trackEvent("import_sql_command_execute", map[string]any{"is_url": isURL}) + + if err := importSQLGates(gateInput{ + FileNameOrURL: fileNameOrURL, IsURL: isURL, Md5: md5Flag, + Launched: info.Launched, AppTypeID: ae.App.TypeId, Info: info, Out: out, + }); err != nil { + return err + } + + domain := info.PrimaryDomainName + if domain == "" { + domain = fmt.Sprintf("#%d", ae.Env.ID) // js:626 + } + formattedEnv := formatEnvironment(ae.Env.Type) + launched := info.Launched + + // fileNameToUpload === fileNameOrURL in Node (js:630, never + // reassigned): the --output copy of a search-replace run is NOT what + // gets uploaded; only --in-place mutates the original. Bug-for-bug. + fileNameToUpload := fileNameOrURL + + var tableNames []string + if !isURL { + tableNames, err = validateAndGetTableNames(cmd, cfg.GQLClient, ae.App.ID, ae.Env.ID, + fileNameToUpload, skipValidate, searchReplace, isMultiSite) + if err != nil { + return err + } + } + + if err := displayPlaybook(out, fileNameOrURL, domain, formattedEnv, ae.App, + launched, isMultiSite, tableNames, searchReplace, info.WPSites, info.WPSitesKnown); err != nil { + return err + } + + if err := promptToContinueImport(cmd, out, launched, formattedEnv, domain, isMultiSite, tableNames); err != nil { + return err + } + + if skipBackup { + confirmed, err := confirmSkipBackup(cmd, out) + if err != nil { + return err + } + if !confirmed { + return nil // Node: process.exit(0) + } + } + + if !isURL && inPlace { + approved, err := importConfirmPrompt(cmd, + "Are you sure you want to run search and replace on your input file? This operation is not reversible.", false) + if err != nil || !approved { + trackEvent("search_replace_in_place_cancelled", map[string]any{"is_import": true, "in_place": inPlace}) + return nil // Node: process.exit() + } + } + + // ===== progress phase: no stray prints below (js:690 WARNING) ===== + pt := tui.NewProgressTracker([]tui.ProgressStep{ + {ID: "replace", Name: "Performing search and replace"}, + {ID: "upload", Name: "Uploading file"}, + {ID: "queue_import", Name: "Queueing import"}, + }) + status := "running" + setPrefixSuffix := func() { + pt.SetPrefix("\n=============================================================\nProcessing the SQL import for your environment...\n") + trailing := "" + if status == "running" { + trailing = "Loading remaining steps" + } + pt.SetSuffix("\n" + tui.GlyphForStatus(tui.StepState(status), tui.SpinnerGlyphs[0]) + " " + trailing) + } + setPrefixSuffix() + renderer := startImportProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + failWithError := func(failureErr error) error { + status = "failed" + setPrefixSuffix() + renderer.stop(cmd, true) + return failureErr + } + + switch { + case localSearchReplaceNeeded(isURL, inPlace, output, searchReplace): + _ = pt.StepRunning("replace") + res, srErr := searchreplace.Run(fileNameOrURL, searchReplace, searchreplace.Options{ + InPlace: inPlace, Output: output, + }) + if srErr != nil { + _ = pt.StepFailed("replace") + return failWithError(srErr) + } + if res.OutputFileName == "" { + _ = pt.StepFailed("replace") + return failWithError(errors.New("Unable to determine location of the intermediate search and replace file.")) + } + _ = pt.StepSuccess("replace") + case !isURL && len(searchReplace) > 0: + // Pairs were given, but the local pass would only write a temp file + // nothing ever opens (see localSearchReplaceNeeded). The server applies + // them from the StartImport payload instead. The step still reports + // success because the replacement IS happening — just not here — so the + // progress output matches Node's. + _ = pt.StepRunning("replace") + _ = pt.StepSuccess("replace") + default: + _ = pt.StepSkipped("replace") + } + + appID := ae.App.ID + envID := ae.Env.ID + input := &gql.AppEnvironmentImportInput{ + Id: &appID, + EnvironmentId: &envID, + SkipMaintenanceMode: &skipMaintenanceMode, + } + if skipBackup { + t := true + input.SkipBackup = &t + } + + if isURL { + _ = pt.StepSkipped("upload") + input.Url = &fileNameOrURL + input.SearchReplace = []*gql.AppEnvironmentImportSearchReplace{} + if md5Flag != "" { + input.Md5 = &md5Flag + } + urlHeaders := make([]*gql.RequestHeader, 0, len(headers)) + for _, h := range headers { + urlHeaders = append(urlHeaders, &gql.RequestHeader{Name: h.Name, Value: h.Value}) + } + input.UrlHeaders = urlHeaders + } else { + _ = pt.StepRunning("upload") + meta, metaErr := upload.GetFileMeta(fileNameToUpload) + if metaErr != nil { + _ = pt.StepFailed("upload") + return failWithError(metaErr) + } + uc := &upload.Client{APIHost: cfg.APIHost, Token: cfg.Token} + res, upErr := uc.UploadImportFile(cmd.Context(), appID, envID, meta, "md5", + func(pct string) { pt.SetUploadPercentage(pct) }) + if upErr != nil { + trackEvent("import_sql_command_error", map[string]any{ + "error_type": "upload_failed", "upload_error": upErr.Error(), + }) + _ = pt.StepFailed("upload") + return failWithError(upErr) + } + basename := res.Meta.BaseName + checksum := res.Checksum + input.Basename = &basename + input.Md5 = &checksum + input.SearchReplace = []*gql.AppEnvironmentImportSearchReplace{} + _ = pt.StepSuccess("upload") + trackEvent("import_sql_upload_complete", nil) + } + + // searchReplace pairs → input.SearchReplace [{from,to}] (js:760-774), + // but only when the uploaded bytes have not already been rewritten. + // DELIBERATE DIVERGENCE — see serverSideSearchReplaceNeeded. + if serverSideSearchReplaceNeeded(isURL, inPlace, searchReplace) { + input.SearchReplace = buildImportSearchReplaceInput(searchReplace) + } + + if _, err := gql.StartImport(gql.WithAllowGQLErrors(cmd.Context()), cfg.GQLClient, input); err != nil { + trackEvent("import_sql_command_error", map[string]any{"error_type": "StartImport-failed"}) + _ = pt.StepFailed("queue_import") + return failWithError(fmt.Errorf("StartImport call failed: %s", err)) + } + _ = pt.StepSuccess("queue_import") + + return importSQLCheckStatus(cmd, pt, renderer, ae, domain, false) +} + +// importPollInterval — VIP_IMPORT_SQL_INTERVAL_MS overrides the 5s Node +// default for tests (the VIP_SYNC_INTERVAL_MS precedent, sync.go:180). +func importPollInterval() time.Duration { + if v := os.Getenv("VIP_IMPORT_SQL_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return siteimport.DefaultPollInterval +} + +// importSQLCheckStatus wraps siteimport.CheckStatus: builds the +// ProgressFetch closure over gql.ImportSQLProgress (including the +// pseudo-job synthesis from importStatus.progress — status.ts:288-328), +// renders the Status/Site suffix block, and maps the terminal outcome to +// Node's output + exit semantics. +func importSQLCheckStatus(cmd *cobra.Command, pt *tui.ProgressTracker, renderer *importProgressRenderer, ae *appctx.AppEnv, domain string, returnFast bool) error { + cfg := GetConfig() + pollCtx := gql.WithAllowGQLErrors(cmd.Context()) + + fetch := func(ctx context.Context) (*siteimport.ProgressSnapshot, error) { + appID := ae.App.ID + envID := ae.Env.ID + resp, err := gql.ImportSQLProgress(ctx, cfg.GQLClient, &appID, &envID) + if err != nil { + return nil, err + } + if resp == nil || resp.App == nil || len(resp.App.Environments) == 0 || resp.App.Environments[0] == nil { + // status.ts:92 — "Unable to determine import status from environment" + return nil, errors.New("Unable to determine import status from environment") + } + env := resp.App.Environments[0] + snap := &siteimport.ProgressSnapshot{} + if env.Launched != nil { + snap.Launched = *env.Launched + } + + var importStatus = env.ImportStatus + var statusSteps []siteimport.JobStep + var statusStartedAt int64 + var failedStep *siteimport.FailedStep + if importStatus != nil && importStatus.Progress != nil { + if importStatus.Progress.Started_at != nil { + statusStartedAt = int64(*importStatus.Progress.Started_at) + } + for _, s := range importStatus.Progress.Steps { + if s == nil { + continue + } + name := "" + if s.Name != nil { + name = *s.Name + } + result := "" + if s.Result != nil { + result = *s.Result + } + statusSteps = append(statusSteps, siteimport.JobStep{ + ID: name, + Name: siteimport.Capitalize(strings.ReplaceAll(name, "_", " ")), + Status: tui.StepState(result), + }) + if result == "failed" && failedStep == nil { + fs := &siteimport.FailedStep{Name: name, Output: nil} + if s.Started_at != nil { + fs.StartedAt = int64(*s.Started_at) + } + for _, o := range s.Output { + if o != nil { + fs.Output = append(fs.Output, *o) + } + } + failedStep = fs + } + } + } + snap.StatusProgressStartedAt = statusStartedAt + snap.FailedStep = failedStep + + if len(env.Jobs) > 0 && env.Jobs[0] != nil { + job := *env.Jobs[0] + ij := &siteimport.ImportJob{} + if c := job.GetCreatedAt(); c != nil { + ij.CreatedAt = *c + } + if c := job.GetCompletedAt(); c != nil { + ij.CompletedAt = *c + } + if p := job.GetProgress(); p != nil { + if p.Status != nil { + ij.Status = *p.Status + } + for _, s := range p.Steps { + if s == nil { + continue + } + step := siteimport.JobStep{} + if s.Id != nil { + step.ID = *s.Id + } + if s.Name != nil { + step.Name = *s.Name + } + if s.Status != nil { + step.Status = tui.StepState(*s.Status) + } + ij.Steps = append(ij.Steps, step) + } + } + snap.Job = ij + return snap, nil + } + + // No k8s job: synthesize from importStatus.progress + // (status.ts:288-328). No steps yet → Job stays nil (wait). + if len(statusSteps) == 0 { + return snap, nil + } + ij := &siteimport.ImportJob{Steps: statusSteps} + anyFailed := false + allSuccess := true + restoreDBPending := false + var maxFinished int64 + for _, s := range importStatus.Progress.Steps { + if s == nil { + continue + } + result := "" + if s.Result != nil { + result = *s.Result + } + name := "" + if s.Name != nil { + name = *s.Name + } + if result == "failed" { + anyFailed = true + } + if result != "success" { + allSuccess = false + } + if name == "restore_db" && result == "" { + restoreDBPending = true + } + if s.Finished_at != nil && int64(*s.Finished_at) > maxFinished { + maxFinished = int64(*s.Finished_at) + } + } + if anyFailed && !restoreDBPending { + ij.Status = "error" + } else if allSuccess { + ij.Status = "success" + ij.CompletedAt = time.Unix(maxFinished, 0).UTC().Format(time.RFC1123) + } + if statusStartedAt > 0 { + ij.CreatedAt = time.Unix(statusStartedAt, 0).UTC().Format(time.RFC1123) + } + snap.Job = ij + return snap, nil + } + + overall := "Checking..." // status.ts:213 + setSuffix := func(createdAt, completedAt string) { + sprite := tui.GlyphForStatus(tui.StepState(overall), tui.SpinnerGlyphs[0]) + formattedCreated := "TBD" + if createdAt != "" { + formattedCreated = createdAt + } + formattedCompleted := "TBD" + if createdAt != "" && completedAt != "" { + formattedCompleted = completedAt + } + exitPrompt := "(Press ^C to hide progress. The import will continue in the background.)" + + var statusMessage string + switch overall { + case "success": + statusMessage = fmt.Sprintf("Success %s imported data should be visible on your site %s.", sprite, domain) + case "running": + if pt.AllStepsSucceeded() { + statusMessage = fmt.Sprintf("Finishing up... %s ", sprite) + } else { + statusMessage = fmt.Sprintf("%s %s", siteimport.Capitalize(overall), sprite) + } + default: + statusMessage = fmt.Sprintf("%s %s", siteimport.Capitalize(overall), sprite) + } + + maybeExitPrompt := "" + if overall == "running" { + maybeExitPrompt = exitPrompt + } + maybeTimestamps := "" + if overall == "running" || overall == "success" || overall == "failed" { + maybeTimestamps = fmt.Sprintf("\nSQL Import Started: %s\nSQL Import Completed: %s", formattedCreated, formattedCompleted) + } + pt.SetSuffix(fmt.Sprintf("\n=============================================================\nStatus: %s\nSite: %s (%s)%s\n=============================================================\n%s\n", + statusMessage, ae.App.Name, formatEnvironment(ae.Env.Type), maybeTimestamps, maybeExitPrompt)) + } + + res, err := siteimport.CheckStatus(pollCtx, siteimport.CheckStatusOpts{ + Fetch: fetch, + Tracker: pt, + Interval: importPollInterval(), + ReturnMissingJobImmediately: returnFast, + OnPoll: func(createdAt, completedAt, _ string) { + setSuffix(createdAt, completedAt) + }, + }) + if err != nil { + var fe *siteimport.ImportFailedError + if errors.As(err, &fe) { + overall = "failed" + renderer.stop(cmd, true) + return errors.New(siteimport.GetErrorMessage(fe)) + } + renderer.stop(cmd, true) + return err + } + + if res.Message != "" { + overall = res.Message // e.g. "No import job found" (status.ts:421) + } else { + overall = res.Status + } + setSuffix(res.CreatedAt, res.CompletedAt) + renderer.stop(cmd, true) + return nil +} diff --git a/cmd/vip-next/commands/import_sql_status.go b/cmd/vip-next/commands/import_sql_status.go new file mode 100644 index 000000000..ae27e15ac --- /dev/null +++ b/cmd/vip-next/commands/import_sql_status.go @@ -0,0 +1,67 @@ +package commands + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/siteimport" + "github.com/Automattic/vip/internal/tui" +) + +// ImportSQLStatusCmd returns `vip import sql status`. +// +// Node parity: src/bin/vip-import-sql-status.js (73 LOC). Re-enters the +// shared status poller with ReturnMissingJobImmediately=true so a quiet +// environment reports "No import job found" instead of polling forever. +func ImportSQLStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Check the status of a SQL database import currently in progress", + Long: "Check the status of the most recent SQL database import to an environment. " + + "If the import is still in progress, the command will poll until the import is complete.", + Args: cobra.NoArgs, + } + addAppEnvFlags(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runImportSQLStatus) +} + +func runImportSQLStatus(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + + if !siteimport.IsSupportedApp(ae.App.TypeId) { + // Node tracks errorType (camelCase) here, unlike vip-import-sql.js's + // error_type — kept bug-for-bug (vip-import-sql-status.js:53). + trackEvent("import_sql_command_error", map[string]any{"errorType": "unsupported-app"}) + return errors.New("The type of application you specified does not currently support SQL imports.") + } + + trackEvent("import_sql_check_status_command_execute", nil) + + pt := tui.NewProgressTracker(nil) + pt.SetPrefix("\n=============================================================\nChecking the SQL import status for your environment...\n") + + renderer := startImportProgressRenderer(cmd, pt) + defer renderer.stop(cmd, false) + + // Domain for the success suffix: Node's status appQuery exposes + // primaryDomain too; reuse the import-sql env info query. + cfg := GetConfig() + domain := "" + if info, err := fetchImportEnvInfo(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID); err == nil { + domain = info.PrimaryDomainName + } + if domain == "" { + domain = "N/A" // status.ts:229 `env.primaryDomain?.name ?? 'N/A'` + } + + return importSQLCheckStatus(cmd, pt, renderer, ae, domain, true) +} diff --git a/cmd/vip-next/commands/import_sql_test.go b/cmd/vip-next/commands/import_sql_test.go new file mode 100644 index 000000000..458747b4f --- /dev/null +++ b/cmd/vip-next/commands/import_sql_test.go @@ -0,0 +1,746 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +func TestParseImportHeaders(t *testing.T) { + hs, err := parseImportHeaders([]string{"Authorization: Bearer x", "X-Empty:"}) + if err != nil { + t.Fatal(err) + } + if hs[0].Name != "Authorization" || hs[0].Value != "Bearer x" { + t.Errorf("h0 = %+v", hs[0]) + } + if hs[1].Name != "X-Empty" || hs[1].Value != "" { + t.Errorf("h1 = %+v", hs[1]) + } + + _, err = parseImportHeaders([]string{"NoColonHere"}) + if err == nil || !strings.Contains(err.Error(), `Invalid header format: "NoColonHere". Expected format: "Name: Value"`) { + t.Errorf("err = %v", err) + } + _, err = parseImportHeaders([]string{": value-only"}) + if err == nil || !strings.Contains(err.Error(), "Header name cannot be empty.") { + t.Errorf("err = %v", err) + } +} + +// Node builds the wire pairs with +// +// pair.split( ',' ).map( str => str.trim() ) // vip-import-sql.js:821 +// { from: arr[0], to: arr[1] } // vip-import-sql.js:823-827 +// +// JS String.split(',') with no limit splits on EVERY comma, so only the +// first two segments survive — "a,b,c" yields {from:"a", to:"b"} and "c" is +// silently dropped. Go's strings.SplitN(pair, ",", 2) instead glued the +// remainder onto `to` ("b,c"), i.e. a different server-side replacement. +func TestParseImportSearchReplacePairSplitsOnFirstCommaOnly(t *testing.T) { + got := parseImportSearchReplacePair("a,b,c") + if got.From != "a" { + t.Errorf("From = %q, want %q", got.From, "a") + } + if got.To != "b" { + t.Errorf("To = %q, want %q (Node discards everything after the 2nd segment)", got.To, "b") + } + if !got.HasTo { + t.Error("HasTo = false, want true") + } +} + +// The destructive one. `--search-replace="a"` gives arr[1] === undefined in +// Node, and JSON.stringify drops undefined properties, so the wire payload +// is {from:"a"} with NO `to` key. Go sent to:"" — which instructs the server +// to replace every occurrence of "a" with the empty string, i.e. delete it. +func TestParseImportSearchReplacePairOmitsToWhenNoComma(t *testing.T) { + got := parseImportSearchReplacePair("a") + if got.From != "a" { + t.Errorf("From = %q, want %q", got.From, "a") + } + if got.HasTo { + t.Error("HasTo = true, want false — Node omits `to` entirely; sending to:\"\" deletes every occurrence of `from`") + } +} + +func TestParseImportSearchReplacePairTrimsSegments(t *testing.T) { + got := parseImportSearchReplacePair(" from.example.com , to.example.com ") + if got.From != "from.example.com" || got.To != "to.example.com" { + t.Errorf("got {%q,%q}, want {from.example.com,to.example.com}", got.From, got.To) + } +} + +// A trailing comma DOES produce a second (empty) segment in JS, so +// "a," legitimately means "replace a with nothing". +func TestParseImportSearchReplacePairTrailingCommaKeepsEmptyTo(t *testing.T) { + got := parseImportSearchReplacePair("a,") + if !got.HasTo || got.To != "" { + t.Errorf("got {To:%q, HasTo:%v}, want {To:\"\", HasTo:true}", got.To, got.HasTo) + } +} + +// The wire payload is where the damage happens: `to` must be absent from +// the JSON, not null and not "". +func TestImportSearchReplaceWirePayloadOmitsMissingTo(t *testing.T) { + pairs := buildImportSearchReplaceInput([]string{"a", "x,y,z"}) + b, err := json.Marshal(pairs) + if err != nil { + t.Fatal(err) + } + got := string(b) + if strings.Contains(got, `"to":""`) { + t.Errorf(`payload contains "to":"" — that deletes every occurrence of "a"; got %s`, got) + } + if strings.Contains(got, `"to":null`) { + t.Errorf(`payload contains "to":null — Node omits the key entirely; got %s`, got) + } + if !strings.Contains(got, `{"from":"a"}`) { + t.Errorf(`want the no-comma pair to serialize as {"from":"a"}; got %s`, got) + } + if !strings.Contains(got, `"to":"y"`) { + t.Errorf(`want "x,y,z" to serialize with to:"y"; got %s`, got) + } +} + +// Node's sql.ts:1 imports `{ stdout as log } from '@wwa/single-line-log'`, so +// `log('Reading line N ')` (sql.ts:533) REWRITES the current line — the user +// sees one counter ticking up. Go printed a fresh newline-terminated line +// every 500 rows, so a 5M-line dump scrolled ~10,000 "Reading line N" lines +// past the actual validation findings. +func TestImportLineTickerOverwritesInsteadOfAppending(t *testing.T) { + var buf bytes.Buffer + tk := newImportLineTicker(&buf, true) + for _, n := range []int{500, 1000, 1500} { + tk.tick(n) + } + tk.done() + + got := buf.String() + // An in-place renderer emits cursor-movement escapes; an appending one + // emits none. + if !strings.Contains(got, "\x1b[") { + t.Errorf("no ANSI cursor movement emitted — the ticker is appending, not overwriting: %q", got) + } + // Every counter value must not survive as its own standing line: the + // erase sequences mean only the last frame is left on screen. Count the + // bare occurrences that are NOT preceded by an erase. + if n := strings.Count(got, "Reading line"); n != 3 { + t.Fatalf("emitted %d frames, want 3", n) + } + if !strings.Contains(got, "Reading line 1500 ") { + t.Errorf("last frame missing (note the Node-parity trailing space): %q", got) + } +} + +// Piped/CI output gets no cursor control from the ticker at all — writing +// 10,000 progress lines into a build log buries the validation report. +func TestImportLineTickerSilentOnNonTTY(t *testing.T) { + var buf bytes.Buffer + tk := newImportLineTicker(&buf, false) + for n := 500; n <= 5000; n += 500 { + tk.tick(n) + } + tk.done() + if got := buf.String(); got != "" { + t.Errorf("non-TTY ticker wrote %q, want no progress chrome", got) + } +} + +// Node's playbook (vip-import-sql.js:489-499) makes a three-way distinction +// that Go collapsed into one hard failure: +// +// if ( siteArray === 'undefined' || ! siteArray ) { // wpSitesSDS null +// console.log( chalk.yellowBright( 'Unable to determine …' ) ); +// return; // ← WARN AND PROCEED +// } else if ( ! siteArray?.length ) { // nodes: [] +// throw new Error( 'There were no sites in your multisite installation.' ); +// } +// +// `siteArray` is `selectedEnvironmentObj?.wpSitesSDS?.nodes`, so a null +// wpSitesSDS (or a null nodes) yields undefined → warn. Only a present, +// genuinely EMPTY node list is fatal. Hard-failing the null case bricks SQL +// import for any multisite whose site catalog the API declines to return. +func TestDisplayPlaybookWarnsAndProceedsWhenWPSitesUnknown(t *testing.T) { + var buf bytes.Buffer + err := displayPlaybook(&buf, "dump.sql", "example.com", "Production", + appctx.App{Name: "app"}, false, true, + []string{"wp_options"}, nil, nil, false /* wpSitesKnown */) + if err != nil { + t.Fatalf("displayPlaybook = %v, want nil — Node warns and proceeds when wpSitesSDS is null", err) + } + if !strings.Contains(buf.String(), "Unable to determine the network sites affected by this import") { + t.Errorf("missing Node's yellow warning; got:\n%s", buf.String()) + } +} + +// The genuinely-empty case stays fatal, exactly as in Node. +func TestDisplayPlaybookErrorsWhenWPSitesKnownButEmpty(t *testing.T) { + var buf bytes.Buffer + err := displayPlaybook(&buf, "dump.sql", "example.com", "Production", + appctx.App{Name: "app"}, false, true, + []string{"wp_options"}, nil, nil, true /* wpSitesKnown */) + if err == nil || !strings.Contains(err.Error(), "There were no sites in your multisite installation.") { + t.Fatalf("err = %v, want 'There were no sites in your multisite installation.'", err) + } +} + +// fetchImportEnvInfo must preserve the null-vs-empty distinction the +// playbook depends on; a flattened []importWPSite alone cannot express it. +func TestFetchImportEnvInfoDistinguishesNullFromEmptyWPSites(t *testing.T) { + cases := []struct { + name string + wpSitesJS string + wantKnown bool + }{ + {"null wpSitesSDS", `"wpSitesSDS":null`, false}, + {"null nodes", `"wpSitesSDS":{"nodes":null}`, false}, + {"empty nodes", `"wpSitesSDS":{"nodes":[]}`, true}, + {"populated nodes", `"wpSitesSDS":{"nodes":[{"id":1,"homeUrl":"https://a.example.com"}]}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `{"data":{"app":{"id":1,"typeId":2,"environments":[{"id":2,"launched":false,` + + `"primaryDomain":{"name":"example.com"},` + + `"importStatus":{"importInProgress":false,"dbOperationInProgress":false},` + + tc.wpSitesJS + `}]}}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + client := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + info, err := fetchImportEnvInfo(context.Background(), client, 1, 2) + if err != nil { + t.Fatalf("fetchImportEnvInfo: %v", err) + } + if info.WPSitesKnown != tc.wantKnown { + t.Errorf("WPSitesKnown = %v, want %v", info.WPSitesKnown, tc.wantKnown) + } + }) + } +} + +func TestIsValidImportURL(t *testing.T) { + // Node isValidUrl (vip-import-sql.js:96): URL-parseable with a real + // protocol; single drive letters (Windows paths) rejected. + for in, want := range map[string]bool{ + "https://example.com/f.sql": true, + "http://u:p@example.com/f": true, + `C:\dumps\file.sql`: false, + "./relative/file.sql": false, + "file.sql": false, + } { + if got := isValidImportURL(in); got != want { + t.Errorf("isValidImportURL(%q) = %v, want %v", in, got, want) + } + } +} + +func TestIsValidMd5(t *testing.T) { + if !isValidMd5("5d41402abc4b2a76b9719d911017c592") { + t.Error("valid md5 rejected") + } + for _, bad := range []string{"", "xyz", "5d41402abc4b2a76b9719d911017c59", "5d41402abc4b2a76b9719d911017c592a"} { + if isValidMd5(bad) { + t.Errorf("%q accepted", bad) + } + } +} + +func validGateInfo() *importEnvInfo { + return &importEnvInfo{HasImportStatus: true} +} + +func TestImportSQLGatesFileChecks(t *testing.T) { + dir := t.TempDir() + mk := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p + } + + okSQL := mk("good.sql", "SELECT 1;\n") + badExt := mk("bad.txt", "SELECT 1;\n") + badName := mk("bad name!.sql", "SELECT 1;\n") + empty := mk("empty.sql", "") + big := mk("big.sql", strings.Repeat("x", 100)) + + cases := []struct { + name string + g gateInput + want string + }{ + {"bad extension", gateInput{FileNameOrURL: badExt, AppTypeID: 2, Info: validGateInfo()}, + "Invalid file extension. Please provide a .sql or .gz file."}, + {"bad filename", gateInput{FileNameOrURL: badName, AppTypeID: 2, Info: validGateInfo()}, + "limited to [0-9,a-z,A-Z,-,_,.]"}, + {"missing file", gateInput{FileNameOrURL: filepath.Join(dir, "nope.sql"), AppTypeID: 2, Info: validGateInfo()}, + "does not exist or is not readable."}, + {"directory", gateInput{FileNameOrURL: dir + "/", AppTypeID: 2, Info: validGateInfo()}, + "does not exist or is not readable."}, // trailing slash fails basename charset? see below + {"empty file", gateInput{FileNameOrURL: empty, AppTypeID: 2, Info: validGateInfo()}, + "is empty."}, + {"too big", gateInput{FileNameOrURL: big, AppTypeID: 2, Info: validGateInfo(), SizeLimit: 10, SizeLimitLaunched: 5}, + "exceeds the limit (10 bytes)."}, + {"too big launched", gateInput{FileNameOrURL: big, AppTypeID: 2, Info: validGateInfo(), Launched: true, SizeLimit: 10, SizeLimitLaunched: 5}, + "This limit is lower for launched environments"}, + {"invalid md5", gateInput{FileNameOrURL: "https://x.example/f.sql", IsURL: true, Md5: "nope", AppTypeID: 2, Info: validGateInfo()}, + "The provided MD5 hash is invalid. It should be a 32-character hexadecimal string."}, + {"unsupported app", gateInput{FileNameOrURL: okSQL, AppTypeID: 3, Info: validGateInfo()}, + "does not currently support SQL imports."}, + {"no import status", gateInput{FileNameOrURL: okSQL, AppTypeID: 2, Info: &importEnvInfo{}}, + "Could not determine the import status for this environment."}, + {"import in progress", gateInput{FileNameOrURL: okSQL, AppTypeID: 2, + Info: &importEnvInfo{HasImportStatus: true, ImportInProgress: true}}, + "There is already an import in progress."}, + {"dbop in progress", gateInput{FileNameOrURL: okSQL, AppTypeID: 2, + Info: &importEnvInfo{HasImportStatus: true, DbOperationInProgress: true}}, + "There is already a database operation in progress. Please try again later."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.g.Out = io.Discard + err := importSQLGates(tc.g) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("err = %v, want substring %q", err, tc.want) + } + }) + } + + t.Run("clean local file passes", func(t *testing.T) { + err := importSQLGates(gateInput{FileNameOrURL: okSQL, AppTypeID: 2, Info: validGateInfo(), Out: io.Discard}) + if err != nil { + t.Errorf("err = %v", err) + } + }) + + t.Run("md5 ignored warning for local file", func(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + err := importSQLGates(gateInput{FileNameOrURL: okSQL, AppTypeID: 2, Info: validGateInfo(), + Md5: "5d41402abc4b2a76b9719d911017c592", Out: &buf}) + if err != nil { + t.Fatalf("err = %v", err) + } + if !strings.Contains(buf.String(), "The --md5 parameter is only valid for imports from a remote URL. This option will be ignored.") { + t.Errorf("missing ignore warning: %q", buf.String()) + } + }) +} + +// importStub serves the GraphQL operations + presign + S3 endpoints the +// full import flow touches. +type importStub struct { + mu sync.Mutex + startImportReq string + uploadedBody []byte + srvURL string +} + +func (s *importStub) start(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + s.srvURL = srv.URL + + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ImportSQLEnvInfo"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[ + {"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true, + "primaryDomain":{"name":"example.com"}, + "importStatus":{"dbOperationInProgress":false,"importInProgress":false}, + "wpSitesSDS":{"nodes":[]}}]}}}`)) + case strings.Contains(bs, `"operationName":"AppMultiSiteCheck"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"parityapp","repo":"r","environments":[ + {"id":7,"appId":42,"name":"develop","type":"develop","isMultisite":false,"isSubdirectoryMultisite":false}]}}}`)) + case strings.Contains(bs, `"operationName":"StartImport"`): + s.mu.Lock() + s.startImportReq = bs + s.mu.Unlock() + _, _ = w.Write([]byte(`{"data":{"startImport":{"app":{"id":42,"name":"parityapp"},"message":"ok","success":true}}}`)) + case strings.Contains(bs, `"operationName":"ImportSQLProgress"`): + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[ + {"id":7,"isK8sResident":true,"launched":false, + "jobs":[{"__typename":"Job","id":1,"type":"sql_import", + "createdAt":"Mon, 01 Jun 2026 00:00:00 UTC","completedAt":"Mon, 01 Jun 2026 00:05:00 UTC", + "progress":{"status":"success","steps":[ + {"id":"import","name":"Importing db","status":"success"}]}}], + "importStatus":{"dbOperationInProgress":false,"importInProgress":false,"progress":null}}]}}}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"url":"%s/s3target","options":{"method":"PUT","headers":{}}}`, s.srvURL) + }) + mux.HandleFunc("/s3target", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.uploadedBody = body + s.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + return srv +} + +// cleanWPDump satisfies every required static check (dropTable, +// createTable, autoIncrement, engineInnoDB) — same shape as the +// validate-sql clean fixture. +const cleanWPDump = "DROP TABLE IF EXISTS `wp_posts`;\n" + + "CREATE TABLE `wp_posts` (\n" + + " `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n" + + " PRIMARY KEY (`ID`)\n" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" + +// multisiteWPDump adds a wp_blogs table so IsMultiSiteSQLDumpLine fires +// while the static checks still pass. +const multisiteWPDump = cleanWPDump + + "DROP TABLE IF EXISTS `wp_blogs`;\n" + + "CREATE TABLE `wp_blogs` (\n" + + " `blog_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n" + + " PRIMARY KEY (`blog_id`)\n" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" + +func importCtx(appID, envID, typeID int64) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "parityapp", TypeId: typeID}, + Env: appctx.Env{ID: envID, Name: "develop", Type: "develop"}, + }) +} + +// stubImportPrompts redirects the prompt indirection vars; restore via +// the returned func. +func stubImportPrompts(inputAnswer string, confirmAnswer bool) func() { + origInput := importInputPrompt + origConfirm := importConfirmPrompt + importInputPrompt = func(_ *cobra.Command, _ string, _ string) (string, error) { + return inputAnswer, nil + } + importConfirmPrompt = func(_ *cobra.Command, _ string, _ bool) (bool, error) { + return confirmAnswer, nil + } + return func() { + importInputPrompt = origInput + importConfirmPrompt = origConfirm + } +} + +func TestImportSQLHappyPathUploadsOriginalFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("go-search-replace stand-in is a POSIX #!/bin/sh script; not executable on Windows") + } + stub := &importStub{} + srv := stub.start(t) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, + Token: "tok", + }) + defer SetConfig(Config{}) + + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_IMPORT_SQL_INTERVAL_MS", "1") + restore := stubImportPrompts("EXAMPLE.COM", true) + defer restore() + + // Fake search-replace binary upper-cases its input; if the command + // wrongly uploaded the s-r output, the uploaded body would be + // upper-case. + binDir := t.TempDir() + bin := filepath.Join(binDir, "go-search-replace") + if err := os.WriteFile(bin, []byte("#!/bin/sh\ntr 'a-z' 'A-Z'\n"), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + + dir := t.TempDir() + sqlPath := filepath.Join(dir, "dump.sql") + content := cleanWPDump + if err := os.WriteFile(sqlPath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + outPath := filepath.Join(dir, "replaced.sql") + + cmd := ImportSQLCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(importCtx(42, 7, 2)) + _ = cmd.Flags().Set("search-replace", "from.example.com,to.example.com") + _ = cmd.Flags().Set("output", outPath) + + if err := runImportSQL(cmd, []string{sqlPath}); err != nil { + t.Fatalf("runImportSQL: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + + stub.mu.Lock() + defer stub.mu.Unlock() + // fileNameToUpload trap (vip-import-sql.js:630): the ORIGINAL file is + // uploaded, not the --output copy. + if string(stub.uploadedBody) != content { + t.Errorf("uploaded body = %q, want original content", stub.uploadedBody) + } + if !strings.Contains(stub.startImportReq, `"basename":"dump.sql"`) { + t.Errorf("StartImport input missing basename: %s", stub.startImportReq) + } + if !strings.Contains(stub.startImportReq, `"searchReplace":[{"from":"from.example.com","to":"to.example.com"}]`) { + t.Errorf("StartImport input missing searchReplace pairs: %s", stub.startImportReq) + } + // The --output copy was still produced by the replace step. + replaced, err := os.ReadFile(outPath) // #nosec G304 + if err != nil { + t.Fatalf("output copy missing: %v", err) + } + if !strings.Contains(string(replaced), "CREATE TABLE `WP_POSTS`") { + t.Errorf("replaced copy = %q", replaced) + } + // Playbook + table list went to stdout. + if !strings.Contains(stdout.String(), "importing: "+sqlPath) { + t.Errorf("stdout missing playbook: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "wp_posts") { + t.Errorf("stdout missing table names: %q", stdout.String()) + } +} + +func TestImportSQLDomainPromptMismatchAborts(t *testing.T) { + stub := &importStub{} + srv := stub.start(t) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, + Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + restore := stubImportPrompts("WRONG.DOMAIN", true) + defer restore() + + dir := t.TempDir() + sqlPath := filepath.Join(dir, "dump.sql") + if err := os.WriteFile(sqlPath, []byte(cleanWPDump), 0o600); err != nil { + t.Fatal(err) + } + + cmd := ImportSQLCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + err := runImportSQL(cmd, []string{sqlPath}) + if err == nil || !strings.Contains(err.Error(), "The input did not match the expected environment label. Import aborted.") { + t.Errorf("err = %v", err) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if stub.startImportReq != "" { + t.Error("StartImport must not fire after an aborted prompt") + } +} + +func TestImportSQLMultisiteMismatchErrors(t *testing.T) { + // Single-site env + multisite dump → site-type validation error. + stub := &importStub{} + srv := stub.start(t) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, + Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + restore := stubImportPrompts("EXAMPLE.COM", true) + defer restore() + + dir := t.TempDir() + sqlPath := filepath.Join(dir, "ms.sql") + if err := os.WriteFile(sqlPath, []byte(multisiteWPDump), 0o600); err != nil { + t.Fatal(err) + } + + cmd := ImportSQLCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + err := runImportSQL(cmd, []string{sqlPath}) + if err == nil || !strings.Contains(err.Error(), "You have provided a multisite SQL dump file for import into a single site (non-multisite).") { + t.Errorf("err = %v", err) + } +} + +func TestImportSQLStatusUnsupportedApp(t *testing.T) { + cmd := ImportSQLStatusCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 3)) // typeId 3 = NodeJS, unsupported + + err := runImportSQLStatus(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "does not currently support SQL imports.") { + t.Errorf("err = %v", err) + } +} + +func TestImportSQLStatusNoJobFastReturn(t *testing.T) { + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ImportSQLEnvInfo"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[ + {"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true, + "primaryDomain":{"name":"example.com"}, + "importStatus":{"dbOperationInProgress":false,"importInProgress":false}, + "wpSitesSDS":{"nodes":[]}}]}}}`)) + case strings.Contains(bs, `"operationName":"ImportSQLProgress"`): + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[ + {"id":7,"isK8sResident":true,"launched":false,"jobs":[], + "importStatus":{"dbOperationInProgress":false,"importInProgress":false,"progress":null}}]}}}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + SetConfig(Config{ + GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), + APIHost: srv.URL, + Token: "tok", + }) + defer SetConfig(Config{}) + t.Setenv("NO_COLOR", "1") + t.Setenv("VIP_IMPORT_SQL_INTERVAL_MS", "1") + + cmd := ImportSQLStatusCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(importCtx(42, 7, 2)) + + if err := runImportSQLStatus(cmd, nil); err != nil { + t.Errorf("no-job fast return must exit clean, got %v", err) + } +} + +// Node applies --search-replace TWICE when --in-place is used on a local file, +// and vip-next inherited it. The mechanism (verified against trunk): +// +// - vip-import-sql.js:577 sets `fileNameToUpload = fileNameOrURL` BEFORE the +// search-replace block at :671, and never reassigns it. +// - Without --in-place the rewritten copy is therefore discarded and the +// ORIGINAL is uploaded, so the server pass at :760 is the only one. Correct. +// - With --in-place the original file itself was rewritten on disk, so the +// upload already carries replaced content -- and :760 is NOT gated on +// isUrl, so the server applies the same pairs a second time. +// +// Idempotent for a domain swap (the second pass matches nothing), compounding +// for a pair like a -> aa, which yields aaaa. That is silent data corruption, +// so vip-next deliberately diverges: send the pairs only when the uploaded +// bytes have NOT already been rewritten. +func TestServerSideSearchReplaceSkippedWhenUploadAlreadyRewritten(t *testing.T) { + pairs := []string{"a,aa"} + tests := []struct { + name string + isURL bool + inPlace bool + pairs []string + want bool + }{ + { + name: "local + --in-place: upload already rewritten, server must NOT repeat", + isURL: false, inPlace: true, pairs: pairs, want: false, + }, + { + name: "local without --in-place: Node discards the rewritten copy and uploads the original, so the server pass is the only one", + isURL: false, inPlace: false, pairs: pairs, want: true, + }, + { + name: "URL: no local pass is possible, server must apply", + isURL: true, inPlace: false, pairs: pairs, want: true, + }, + { + name: "no pairs: nothing to send", + isURL: false, inPlace: false, pairs: nil, want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := serverSideSearchReplaceNeeded(tc.isURL, tc.inPlace, tc.pairs) + if got != tc.want { + t.Errorf("serverSideSearchReplaceNeeded(isURL=%v, inPlace=%v, pairs=%v) = %v, want %v", + tc.isURL, tc.inPlace, tc.pairs, got, tc.want) + } + }) + } +} + +// The local search-replace pass is only observable when it writes somewhere the +// user can see: --in-place (rewrites their file, and is the only thing that +// changes what gets uploaded) or an explicit --output (an inspectable artifact). +// +// Node runs it unconditionally for local files and then DISCARDS the result -- +// `outputFileName` is destructured at vip-import-sql.js:674, type-checked at +// :681, and never referenced again -- because `fileNameToUpload` was already +// pinned to the original at :577. The server does the real replacement from the +// pairs in the StartImport payload. So on the default path Node reads and +// rewrites the entire dump to a temp file that nothing ever opens: on a 5 GB +// dump, 5 GB read and 5 GB written for nothing. +// +// Skipping it changes no imported bytes -- only cost. +func TestLocalSearchReplaceOnlyRunsWhenItsOutputIsObservable(t *testing.T) { + pairs := []string{"old.com,new.com"} + tests := []struct { + name string + isURL bool + inPlace bool + output string + pairs []string + want bool + }{ + {name: "--in-place: rewrites the user's file, and changes what is uploaded", + isURL: false, inPlace: true, output: "", pairs: pairs, want: true}, + {name: "explicit --output: the user asked for the artifact", + isURL: false, inPlace: false, output: "clean.sql", pairs: pairs, want: true}, + {name: "neither: Node writes a temp file and discards it, server does the work", + isURL: false, inPlace: false, output: "", pairs: pairs, want: false}, + {name: "URL: Node never runs a local pass", + isURL: true, inPlace: false, output: "", pairs: pairs, want: false}, + {name: "no pairs: nothing to replace", + isURL: false, inPlace: false, output: "", pairs: nil, want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := localSearchReplaceNeeded(tc.isURL, tc.inPlace, tc.output, tc.pairs) + if got != tc.want { + t.Errorf("localSearchReplaceNeeded(isURL=%v, inPlace=%v, output=%q, pairs=%v) = %v, want %v", + tc.isURL, tc.inPlace, tc.output, tc.pairs, got, tc.want) + } + }) + } +} diff --git a/cmd/vip-next/commands/import_validate_files.go b/cmd/vip-next/commands/import_validate_files.go new file mode 100644 index 000000000..8f97c2248 --- /dev/null +++ b/cmd/vip-next/commands/import_validate_files.go @@ -0,0 +1,157 @@ +package commands + +import ( + "context" + "fmt" + "os" + + json "encoding/json/v2" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/validatefiles" +) + +// ImportValidateFilesCmd returns `vip import validate-files <folder>`. +// +// Node parity: src/bin/vip-import-validate-files.js. Always exits 0 — +// findings are informational. Needs GraphQL only for the validation +// config (mediaImportConfig); no app/env context. +func ImportValidateFilesCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate-files <folder>", + Short: "Validate the directory structure and contents of a local media directory", + Long: "Validate the directory structure, file extensions, file names, and file sizes of a " + + "local directory of media files against the WordPress VIP recommended structure " + + "(`uploads/year/month`, or `uploads/sites/<siteID>/year/month` for multisites).", + Args: cobra.ExactArgs(1), + RunE: runImportValidateFiles, + } +} + +func runImportValidateFiles(cmd *cobra.Command, args []string) error { + cfg := GetConfig() + out := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + filePath := args[0] + + trackEvent("import_validate_files_command_execute", nil) + + fi, err := os.Stat(filePath) + if err != nil || !fi.IsDir() { + // js:33-39 — error to stderr, exit 0. + fmt.Fprintln(errW, color.RedString("✕ Error:"), + "The given path is not a directory. Provide a valid directory path.") + return nil + } + + // Folder walk (js:50). nil → walk error already printed; exit 0. + nested := validatefiles.FindNestedDirectories(filePath, errW) + if nested == nil { + return nil + } + + var folderValidation []string + if len(nested.Folders) > 0 { + folderValidation = validatefiles.FolderStructureValidation(nested.Folders, out) + } + + if len(nested.Files) == 0 { + // js:73-75 — prints but CONTINUES (bug-for-bug). + fmt.Fprintln(errW, color.RedString("✕ Error:"), "The media files directory cannot be empty.") + } + + // Media import config (js:80). + mediaCfg, cfgErr := fetchMediaImportConfig(cmd, cfg) + if cfgErr != nil || mediaCfg == nil { + fmt.Fprintln(errW, color.RedString("✕ Error:"), + "Could not retrieve validation metadata. Please contact VIP Support.") + return nil + } + + res := validatefiles.ValidateFiles(nested.Files, *mediaCfg) + + // Error logging (js:107-130). + var allowedTypeKeys []string + for k := range mediaCfg.AllowedFileTypes { + allowedTypeKeys = append(allowedTypeKeys, k) + } + validatefiles.LogErrors(out, validatefiles.LogErrorsOptions{ + ErrorType: validatefiles.ErrInvalidTypes, InvalidFiles: res.ErrorFileTypes, + AllowedTypes: allowedTypeKeys, + }) + validatefiles.LogErrors(out, validatefiles.LogErrorsOptions{ + ErrorType: validatefiles.ErrInvalidSizes, InvalidFiles: res.ErrorFileSizes, + Limit: mediaCfg.FileSizeLimitInBytes, + }) + validatefiles.LogErrors(out, validatefiles.LogErrorsOptions{ + ErrorType: validatefiles.ErrInvalidNameCharCounts, InvalidFiles: res.ErrorFileNamesCharCount, + Limit: mediaCfg.FileNameCharCount, + }) + validatefiles.LogErrors(out, validatefiles.LogErrorsOptions{ + ErrorType: validatefiles.ErrInvalidNames, InvalidFiles: res.ErrorFileNames, + }) + validatefiles.LogErrors(out, validatefiles.LogErrorsOptions{ + ErrorType: validatefiles.ErrIntermediateImages, + InvalidFiles: validatefiles.SortedKeys(res.IntermediateImages), + IntermediateImages: res.IntermediateImages, + }) + + // Summary (js:133-142). + validatefiles.SummaryLogs(out, validatefiles.SummaryParams{ + FolderErrorsLength: len(folderValidation), + IntImagesErrorsLength: res.IntermediateImagesTotal, + FileTypeErrorsLength: len(res.ErrorFileTypes), + FileErrorFileSizesLength: len(res.ErrorFileSizes), + FilenameErrorsLength: len(res.ErrorFileNames), + FileNameCharCountErrorsLength: len(res.ErrorFileNamesCharCount), + TotalFiles: len(nested.Files), + TotalFolders: len(nested.Folders), + }) + + trackEvent("import_validate_files_command_success", map[string]any{ + "folder_errors_length": len(folderValidation), + "int_images_errors_length": res.IntermediateImagesTotal, + "file_type_errors_length": len(res.ErrorFileTypes), + "filename_errors_length": len(res.ErrorFileNames), + "total_files": len(nested.Files), + "total_folders": len(nested.Folders), + }) + return nil +} + +// fetchMediaImportConfig wraps gql.MediaImportConfig (media-import/ +// config.ts:18) and flattens it into validatefiles.Config. The +// allowedFileTypes scalar arrives as raw JSON ({ext: label}). +func fetchMediaImportConfig(cmd *cobra.Command, cfg Config) (*validatefiles.Config, error) { + // No appctx middleware on this command — cobra's Context() can be nil + // when invoked directly (tests, legacy dispatch). + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + resp, err := gql.MediaImportConfig(gql.WithAllowGQLErrors(ctx), cfg.GQLClient) + if err != nil { + return nil, err + } + if resp == nil || resp.MediaImportConfig == nil { + return nil, nil + } + mic := resp.MediaImportConfig + out := &validatefiles.Config{AllowedFileTypes: map[string]string{}} + if mic.FileNameCharCount != nil { + out.FileNameCharCount = *mic.FileNameCharCount + } + if mic.FileSizeLimitInBytes != nil { + out.FileSizeLimitInBytes = *mic.FileSizeLimitInBytes + } + if mic.AllowedFileTypes != nil && len(*mic.AllowedFileTypes) > 0 { + // Strict decode of the {ext: label} scalar payload. + if err := json.Unmarshal(*mic.AllowedFileTypes, &out.AllowedFileTypes); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/cmd/vip-next/commands/import_validate_files_test.go b/cmd/vip-next/commands/import_validate_files_test.go new file mode 100644 index 000000000..2920c8854 --- /dev/null +++ b/cmd/vip-next/commands/import_validate_files_test.go @@ -0,0 +1,157 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +func mediaConfigStub(t *testing.T, configBody string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(string(body), `"operationName":"MediaImportConfig"`) { + _, _ = w.Write([]byte(configBody)) + return + } + _, _ = w.Write([]byte(`{"data":null}`)) + })) + t.Cleanup(srv.Close) + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), APIHost: srv.URL, Token: "tok"}) + t.Cleanup(func() { SetConfig(Config{}) }) +} + +func TestImportValidateFilesNotADirectory(t *testing.T) { + t.Setenv("NO_COLOR", "1") + f := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := ImportValidateFilesCmd() + var stderr bytes.Buffer + cmd.SetOut(io.Discard) + cmd.SetErr(&stderr) + + if err := runImportValidateFiles(cmd, []string{f}); err != nil { + t.Fatalf("must exit 0, got %v", err) + } + if !strings.Contains(stderr.String(), "The given path is not a directory. Provide a valid directory path.") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestImportValidateFilesNilConfig(t *testing.T) { + t.Setenv("NO_COLOR", "1") + mediaConfigStub(t, `{"data":{"mediaImportConfig":null}}`) + + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "uploads/2020/06"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "uploads/2020/06/a.jpg"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := ImportValidateFilesCmd() + var stderr bytes.Buffer + cmd.SetOut(io.Discard) + cmd.SetErr(&stderr) + + if err := runImportValidateFiles(cmd, []string{filepath.Join(root, "uploads")}); err != nil { + t.Fatalf("must exit 0, got %v", err) + } + if !strings.Contains(stderr.String(), "Could not retrieve validation metadata. Please contact VIP Support.") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestImportValidateFilesHappyRun(t *testing.T) { + t.Setenv("NO_COLOR", "1") + mediaConfigStub(t, `{"data":{"mediaImportConfig":{ + "fileNameCharCount":255,"fileSizeLimitInBytes":1073741824, + "allowedFileTypes":{"jpg":"image/jpeg","png":"image/png"}}}}`) + + root := t.TempDir() + // The walk starts at <root>/uploads, so folder paths look like + // /tmp/.../uploads/2020/06 — "uploads" is NOT index 0 of the split + // path, mirroring Node behavior for absolute inputs. Structure + // recommendations fire, file checks pass. + dir := filepath.Join(root, "uploads/2020/06") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"a.jpg", "b.png"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + cmd := ImportValidateFilesCmd() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := runImportValidateFiles(cmd, []string{filepath.Join(root, "uploads")}); err != nil { + t.Fatalf("must exit 0, got %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + // File-level checks all pass → no ERROR badges; folder structure is + // "RECOMMENDED" because the absolute path doesn't start at uploads. + if strings.Contains(out, "ERROR") { + t.Errorf("unexpected ERROR badge:\n%s", out) + } + if !strings.Contains(out, "2 files total") || !strings.Contains(out, "folders total") { + t.Errorf("summary missing:\n%s", out) + } + if !strings.Contains(out, "0 invalid file extensions") { + t.Errorf("extension pass line missing:\n%s", out) + } +} + +func TestImportValidateFilesFindingsLogged(t *testing.T) { + t.Setenv("NO_COLOR", "1") + mediaConfigStub(t, `{"data":{"mediaImportConfig":{ + "fileNameCharCount":255,"fileSizeLimitInBytes":1073741824, + "allowedFileTypes":{"jpg":"image/jpeg"}}}}`) + + root := t.TempDir() + dir := filepath.Join(root, "uploads/2020/06") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"good.jpg", "evil.exe", "bad+name.jpg"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + + cmd := ImportValidateFilesCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + + if err := runImportValidateFiles(cmd, []string{filepath.Join(root, "uploads")}); err != nil { + t.Fatalf("must exit 0, got %v", err) + } + out := stdout.String() + if !strings.Contains(out, "File extensions: Invalid file type for file: ") || + !strings.Contains(out, "evil.exe") { + t.Errorf("extension finding missing:\n%s", out) + } + if !strings.Contains(out, "Character validation: Invalid filename for file: ") || + !strings.Contains(out, "bad+name.jpg") { + t.Errorf("filename finding missing:\n%s", out) + } + if !strings.Contains(out, "1 invalid file extensions") { + t.Errorf("summary error line missing:\n%s", out) + } +} diff --git a/cmd/vip-next/commands/import_validate_sql.go b/cmd/vip-next/commands/import_validate_sql.go new file mode 100644 index 000000000..1821c60d7 --- /dev/null +++ b/cmd/vip-next/commands/import_validate_sql.go @@ -0,0 +1,207 @@ +package commands + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/sqlvalidation" +) + +// ImportValidateSQLCmd returns `vip import validate-sql <FILE>`. +// +// Node parity: src/bin/vip-import-validate-sql.js (24 lines) → +// src/lib/validations/sql.ts validate(). Local-only: no GraphQL, no +// appctx middleware. validate() runs with isImport=false and +// skipChecks=DEV_ENV_SPECIFIC_CHECKS (useStatement + siteHomeUrlLando), +// matching what we register in internal/sqlvalidation. +func ImportValidateSQLCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate-sql <FILE>", + Short: "Scan a SQL dump for VIP Platform compatibility issues", + Long: "Scan a local SQL file for syntactically valid but platform-incompatible statements " + + "(e.g. DROP DATABASE, TRIGGER, ALTER USER, non-InnoDB ENGINE) plus detect whether the " + + "dump is from a WordPress multisite installation.\n\n" + + "Mirrors Node's `vip import validate-sql` (src/lib/validations/sql.ts). Compressed " + + "files (gzip, zip — detected from the file's contents, not its name) are not " + + "supported; extract first and re-run.", + Args: cobra.ExactArgs(1), + RunE: runImportValidateSQL, + } +} + +// Magic numbers Node checks for in detectCompressedMimeType +// (src/lib/client-file-uploader.ts:458-476). +var ( + zipMagic = []byte{0x50, 0x4b, 0x03, 0x04} // "PK\x03\x04" + gzipMagic = []byte{0x1f, 0x8b} +) + +// detectCompressedMimeType ports Node's detectCompressedMimeType: read the +// first 4 bytes of the file and match them against the zip / gzip magic +// numbers. Returns "application/zip", "application/gzip", or "". +// +// Node reads into a zero-filled 4-byte buffer, so a file shorter than the +// prefix cannot accidentally match (a lone 0x1f hexes to "1f000000"). +// Reading fewer than len(magic) bytes here has the same effect. +func detectCompressedMimeType(path string) string { + f, err := os.Open(path) // #nosec G304 -- path is a user-supplied CLI arg + if err != nil { + // Node throws here; we defer to the caller's os.Open so the user + // gets the Node-parity "missing or not readable" message instead. + return "" + } + defer f.Close() + + var header [4]byte + n, err := io.ReadFull(f, header[:]) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "" + } + got := header[:n] + + if bytes.HasPrefix(got, zipMagic) { + return "application/zip" + } + if bytes.HasPrefix(got, gzipMagic) { + return "application/gzip" + } + return "" +} + +// fileMetaIsCompressed ports the isCompressed field of Node's getFileMeta +// (client-file-uploader.ts:153): +// +// const isCompressed = [ 'application/zip', 'application/gzip' ].includes( mimeType ); +// +// Detection is by MAGIC BYTES, never by extension. Extension sniffing was +// wrong both ways: it validated raw gzip bytes as if they were SQL whenever +// the file wasn't named .gz, and it refused to validate a plain SQL file +// that merely happened to be named .gz. +func fileMetaIsCompressed(path string) bool { + switch detectCompressedMimeType(path) { + case "application/zip", "application/gzip": + return true + } + return false +} + +func runImportValidateSQL(cmd *cobra.Command, args []string) error { + path := args[0] + trackEvent("import_validate_sql_command_execute", map[string]any{"is_import": false}) + + if fileMetaIsCompressed(path) { + err := errors.New("Compressed files cannot be validated. Please extract the archive and re-run the command, providing the path to the extracted SQL file.") + trackEvent("import_validate_sql_command_error", map[string]any{"error": "compressed"}) + return err + } + + f, err := os.Open(path) // #nosec G304 -- path is a user-supplied CLI arg + if err != nil { + // Node parity: getReadInterface() wraps any open failure with a + // generic missing/unreadable message — see line-by-line.ts:29. + trackEvent("import_validate_sql_command_error", map[string]any{"error": "open"}) + return errors.New("The file at the provided path is either missing or not readable. Please check the input and try again.") + } + defer f.Close() + + res, err := sqlvalidation.Validate(f) + if err != nil { + trackEvent("import_validate_sql_command_error", map[string]any{"error": err.Error()}) + return err + } + + failureReport, problems, errorSummary := renderValidationReport(cmd.OutOrStdout(), res) + + // Node parity (src/lib/validations/sql.ts::validate): when problemsFound + // > 0, Node calls exit.withError which exits 1. Mirror that so CI + // pipelines using `vip import validate-sql && deploy` short-circuit on + // findings. Zero findings -> exit 0. + if problems > 0 { + trackEvent("import_validate_sql_command_failure", map[string]any{ + "is_import": false, + "error": errorSummary, + }) + return errors.New(failureReport) + } + trackEvent("import_validate_sql_command_success", map[string]any{"is_import": false}) + return nil +} + +// renderValidationReport prints the stdout portion of Node's validate-sql +// output and returns the failure report for the shared stderr exit path: +// +// - "Finished processing N lines." + blank line (Node sql.ts:413-415, +// only when isImport === false; validate-sql always is). +// - For each registered check: errors / warnings / infos via the +// check's formatter. Order matches insertion order. +// - Duplicate table-name detection (sql.ts:442-456). +// - Warning block (none for validate-sql since the only warning-producing +// check is the skipped siteHomeUrlLando, but mirroring the flow keeps +// the code aligned with Node). +// - Error block + bold-red "SQL validation failed due to N error(s)" +// footer returned as one error string, so exit.withError places the whole +// report on stderr. +// - Info block on success. +func renderValidationReport(w io.Writer, res *sqlvalidation.Result) (failureReport string, problems int, errorSummary map[string]int) { + // Node parity: src/lib/validations/sql.ts:413 emits log("Finished + // processing N lines.") + log("\n"). Both Node `log` calls add their + // own \n, producing TWO newlines total after the header (the literal + // "\n" string + log's trailing newline). One Fprintln here equals + // one extra blank line, matching Node exactly. + fmt.Fprintf(w, "Finished processing %d lines.\n", res.LinesProcessed) + fmt.Fprintln(w) + + var errLines []string + var warnLines []string + var infoLines []string + errorSummary = make(map[string]int, len(res.Checks)+1) + + for _, check := range res.Checks { + errs, warns, infos, p := formatCheck(check, false) + errLines = append(errLines, errs...) + warnLines = append(warnLines, warns...) + infoLines = append(infoLines, infos...) + problems += p + errorSummary[check.Key] = len(check.Results) + } + // Node builds this summary before its separate duplicate-table check, so + // preserve that ordering even though the final failure count can be one + // higher when duplicate table names are the only finding. + errorSummary["problems_found"] = problems + + // Duplicate table-name detection — Node sql.ts:442. + if dups := findDuplicateTables(res.TableNames); len(dups) > 0 { + problems++ + errLines = append(errLines, + formatErrorLine("Duplicate table names were found: "+strings.Join(dups, ",")), + formatRecLine("Ensure that there are no duplicate tables in your SQL dump"), + "", + ) + } + + if len(warnLines) > 0 { + fmt.Fprintln(w, strings.Join(warnLines, "\n")) + fmt.Fprintln(w) + } + + if problems > 0 { + // Node sql.ts:489: errorOutput joined with "\n", then bold red footer. + errLines = append(errLines, color.New(color.FgRed, color.Bold).Sprint( + "SQL validation failed due to "+strconv.Itoa(problems)+" error(s)")) + return strings.Join(errLines, "\n"), problems, errorSummary + } + + // Success path: dump infos. + fmt.Fprintln(w, strings.Join(infoLines, "\n")) + fmt.Fprintln(w) + + return "", 0, errorSummary +} diff --git a/cmd/vip-next/commands/import_validate_sql_test.go b/cmd/vip-next/commands/import_validate_sql_test.go new file mode 100644 index 000000000..61339a07c --- /dev/null +++ b/cmd/vip-next/commands/import_validate_sql_test.go @@ -0,0 +1,293 @@ +package commands + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/telemetry" +) + +type recordingTelemetryClient struct { + events []string +} + +func (c *recordingTelemetryClient) TrackEvent(name string, _ map[string]any) error { + c.events = append(c.events, name) + return nil +} + +// writeTempSQL writes content to a fresh tempfile and returns its path. +func writeTempSQL(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "dump.sql") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp sql: %v", err) + } + return path +} + +// runValidateSQL executes the validate-sql leaf against a path and returns +// (stdout, returnedError). Tests assert on stdout content + nil error. +func runValidateSQL(t *testing.T, path string) (string, error) { + t.Helper() + cmd := ImportValidateSQLCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + err := runImportValidateSQL(cmd, []string{path}) + return stdout.String(), err +} + +func TestImportValidateSQLClean(t *testing.T) { + clean := strings.Join([]string{ + "-- A clean WP dump.", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + }, "\n") + "\n" + path := writeTempSQL(t, clean) + out, err := runValidateSQL(t, path) + if err != nil { + t.Fatalf("runImportValidateSQL: %v", err) + } + if strings.Contains(out, "SQL file looks clean") { + t.Errorf("clean dump must stop after Node's per-check info block; got additive summary:\n%s", out) + } + // Required checks should report 1 hit each. + if !strings.Contains(out, "CREATE TABLE was found 1 times.") { + t.Errorf("expected CREATE TABLE info; got:\n%s", out) + } + if !strings.Contains(out, "DROP TABLE was found 1 times.") { + t.Errorf("expected DROP TABLE info; got:\n%s", out) + } +} + +func TestImportValidateSQLDetectsMultiSite(t *testing.T) { + // Minimal dump that satisfies all required checks (DROP TABLE, + // CREATE TABLE, AUTO_INCREMENT, ENGINE=InnoDB) so multisite detection + // is the ONLY behavior under test. No findings -> no exit-1 error. + dump := strings.Join([]string{ + "DROP TABLE IF EXISTS `wp_2_options`;", + "CREATE TABLE `wp_2_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + }, "\n") + "\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + if err != nil { + t.Fatalf("runImportValidateSQL: %v", err) + } + if !strings.Contains(out, "wp_n_ prefix tables found: 1") { + t.Errorf("multisite dump output missing Node's prefix count; got:\n%s", out) + } + if strings.Contains(out, "Notice: this looks like a multi-site SQL dump") { + t.Errorf("multisite dump must not add a message Node never emits; got:\n%s", out) + } +} + +func TestImportValidateSQLDropDatabase(t *testing.T) { + dump := "DROP DATABASE foo;\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + // Node parity: findings -> exit 1. Handler returns an error. + if err == nil { + t.Fatal("expected error return when DROP DATABASE finding is present") + } + if strings.Contains(out, "DROP DATABASE statement") { + t.Errorf("failure findings belong in the returned error, not stdout; got:\n%s", out) + } + if !strings.Contains(err.Error(), "DROP DATABASE statement on line(s) 1.") { + t.Errorf("returned error missing DROP DATABASE finding; got:\n%s", err) + } +} + +func TestImportValidateSQLFindingTracksFailureNotSuccess(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + client := &recordingTelemetryClient{} + SetConfig(Config{Tracker: &telemetry.Tracker{Clients: []telemetry.Client{client}}}) + t.Cleanup(func() { SetConfig(Config{}) }) + + path := writeTempSQL(t, "DROP DATABASE foo;\n") + _, err := runValidateSQL(t, path) + if err == nil { + t.Fatal("expected SQL finding error") + } + + events := strings.Join(client.events, ",") + if !strings.Contains(events, "import_validate_sql_command_failure") { + t.Fatalf("events = %q, want failure telemetry", events) + } + if strings.Contains(events, "import_validate_sql_command_success") { + t.Fatalf("events = %q, must not report success for findings", events) + } +} + +func TestImportValidateSQLTrigger(t *testing.T) { + dump := "CREATE DEFINER=`root`@`localhost` TRIGGER foo BEFORE INSERT\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + if err == nil { + t.Fatal("expected error return when TRIGGER finding is present") + } + if strings.Contains(out, "TRIGGER statement") { + t.Errorf("failure findings belong in the returned error, not stdout; got:\n%s", out) + } + if !strings.Contains(err.Error(), "TRIGGER statement on line(s) 1.") { + t.Errorf("returned error missing TRIGGER finding; got:\n%s", err) + } +} + +func TestImportValidateSQLAlterUser(t *testing.T) { + dump := "ALTER USER 'root'@'localhost' IDENTIFIED BY 'x';\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + if err == nil { + t.Fatal("expected error return when ALTER USER finding is present") + } + if strings.Contains(out, "ALTER USER statement") { + t.Errorf("failure findings belong in the returned error, not stdout; got:\n%s", out) + } + if !strings.Contains(err.Error(), "ALTER USER statement on line(s) 1.") { + t.Errorf("returned error missing ALTER USER finding; got:\n%s", err) + } +} + +func TestImportValidateSQLBinaryLogging(t *testing.T) { + dump := "SET @@SESSION.sql_log_bin=0;\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + if err == nil { + t.Fatal("expected error return when sql_log_bin finding is present") + } + if strings.Contains(out, "SET @@SESSION.sql_log_bin statement") { + t.Errorf("failure findings belong in the returned error, not stdout; got:\n%s", out) + } + if !strings.Contains(err.Error(), "SET @@SESSION.sql_log_bin statement on line(s) 1.") { + t.Errorf("returned error missing sql_log_bin finding; got:\n%s", err) + } +} + +func TestImportValidateSQLMissingFile(t *testing.T) { + cmd := ImportValidateSQLCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := runImportValidateSQL(cmd, []string{"/nonexistent/path/file.sql"}) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "missing or not readable") { + t.Errorf("error wording: got %q, want Node-parity 'missing or not readable'", err.Error()) + } +} + +// writeTempBytes writes raw bytes under an arbitrary basename so the test +// can decouple the file's NAME from its CONTENT. +func writeTempBytes(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path +} + +// gzipBytes returns a real gzip stream (starts with the 1f8b magic number). +func gzipBytes(t *testing.T, payload string) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + if _, err := zw.Write([]byte(payload)); err != nil { + t.Fatalf("gzip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("gzip close: %v", err) + } + return buf.Bytes() +} + +func TestImportValidateSQLCompressedRejected(t *testing.T) { + path := writeTempBytes(t, "dump.sql.gz", gzipBytes(t, "SELECT 1;\n")) + cmd := ImportValidateSQLCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := runImportValidateSQL(cmd, []string{path}) + if err == nil { + t.Fatal("expected error for gzip file") + } + if !strings.Contains(err.Error(), "Compressed files cannot be validated") { + t.Errorf("compressed-file error wording: got %q", err.Error()) + } +} + +// Node's getFileMeta calls detectCompressedMimeType (client-file-uploader.ts:458), +// which reads the first 4 bytes and compares them against 504b0304 / 1f8b. +// The file EXTENSION is never consulted. Extension-sniffing is wrong in both +// directions; this is the "gzipped file that isn't named .gz" direction — +// feeding raw gzip bytes to the line validator produces garbage findings +// instead of the actionable "extract the archive" message. +func TestDetectCompressedGzipContentRegardlessOfExtension(t *testing.T) { + path := writeTempBytes(t, "dump.sql", gzipBytes(t, "CREATE TABLE `wp_x` (id int);\n")) + if !fileMetaIsCompressed(path) { + t.Error("gzip content under a .sql name reported as uncompressed (extension sniffing)") + } +} + +func TestDetectCompressedZipContentRegardlessOfExtension(t *testing.T) { + // PK\x03\x04 — the local file header magic Node checks for. + path := writeTempBytes(t, "dump.sql", []byte("PK\x03\x04rest-of-archive")) + if !fileMetaIsCompressed(path) { + t.Error("zip content under a .sql name reported as uncompressed") + } +} + +// ...and the other direction: a plain SQL file that merely happens to be +// NAMED .gz must be validated, not rejected. Extension sniffing refused to +// validate it at all. +func TestDetectCompressedPlainSQLNamedGzIsNotCompressed(t *testing.T) { + path := writeTempBytes(t, "dump.sql.gz", []byte("CREATE TABLE `wp_x` (id int);\n")) + if fileMetaIsCompressed(path) { + t.Error("plain SQL named .gz reported as compressed (extension sniffing)") + } +} + +// A file shorter than the 4-byte probe must not be misread. Node allocates a +// 4-byte zero-filled buffer, so a 1-byte 0x1f file hexes to "1f000000" and +// does NOT match 1f8b. +func TestDetectCompressedShortFileIsNotCompressed(t *testing.T) { + path := writeTempBytes(t, "tiny.sql", []byte{0x1f}) + if fileMetaIsCompressed(path) { + t.Error("1-byte 0x1f file misdetected as gzip") + } + empty := writeTempBytes(t, "empty.sql", nil) + if fileMetaIsCompressed(empty) { + t.Error("empty file misdetected as compressed") + } +} + +func TestImportValidateSQLDuplicateTables(t *testing.T) { + dump := strings.Join([]string{ + "CREATE TABLE `wp_users` (id int);", + "CREATE TABLE `wp_users` (id int);", + }, "\n") + "\n" + path := writeTempSQL(t, dump) + out, err := runValidateSQL(t, path) + if err == nil { + t.Fatal("expected error return when duplicate-table finding is present") + } + if strings.Contains(out, "Duplicate table names were found: wp_users") { + t.Errorf("failure findings belong in the returned error, not stdout; got:\n%s", out) + } + if !strings.Contains(err.Error(), "Duplicate table names were found: wp_users") { + t.Errorf("returned error missing duplicate-table finding; got:\n%s", err) + } +} diff --git a/cmd/vip-next/commands/login.go b/cmd/vip-next/commands/login.go new file mode 100644 index 000000000..204cca593 --- /dev/null +++ b/cmd/vip-next/commands/login.go @@ -0,0 +1,53 @@ +package commands + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" +) + +// trackerAdapter adapts the package trackEvent helper to auth.Tracker. +type trackerAdapter struct{ track func(string, map[string]any) } + +func (a trackerAdapter) Track(name string, props map[string]any) { + if a.track != nil { + a.track(name, props) + } +} + +// LoginCmd returns `vip login`. Node parity: src/bin/vip.js runLoginFlow +// (the flow lives in internal/auth/login.go and is already tested). login is +// on the auth-bypass list, so it builds its own Store + tracker. +func LoginCmd() *cobra.Command { + return &cobra.Command{ + Use: "login", + Short: "Authenticate VIP-CLI with a Personal Access Token", + Long: "Authenticate your installation of VIP-CLI with your Personal Access Token.", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := GetConfig() + store := auth.NewStore(keychain.New(cfg.APIHost)) + + var alias func(int64) + if cfg.Tracker != nil { + alias = cfg.Tracker.AliasUser + } + flow := auth.NewProductionLoginFlow(store, trackerAdapter{track: trackEvent}, alias) + + if _, err := flow.Run(); err != nil { + // Node parity: cancel + printed validation failures end the command + // without a non-zero exit (the flow already printed the message). + // Only unexpected errors (keychain Save, prompt I/O) surface as exit 1. + if errors.Is(err, auth.ErrLoginCancelled) || auth.IsHandledLoginError(err) { + return nil + } + return err + } + return nil + }, + } +} diff --git a/cmd/vip-next/commands/login_test.go b/cmd/vip-next/commands/login_test.go new file mode 100644 index 000000000..f215f5ff4 --- /dev/null +++ b/cmd/vip-next/commands/login_test.go @@ -0,0 +1,25 @@ +package commands + +import ( + "errors" + "testing" + + "github.com/Automattic/vip/internal/auth" +) + +func TestLoginCmdSwallowsHandledErrors(t *testing.T) { + for _, err := range []error{auth.ErrLoginCancelled, auth.ErrTokenExpired, auth.ErrTokenInvalid} { + if !(errors.Is(err, auth.ErrLoginCancelled) || auth.IsHandledLoginError(err)) { + t.Errorf("%v should be treated as a clean exit", err) + } + } +} + +func TestTrackerAdapterForwards(t *testing.T) { + var got string + a := trackerAdapter{track: func(name string, _ map[string]any) { got = name }} + a.Track("login_command_execute", nil) + if got != "login_command_execute" { + t.Errorf("adapter did not forward: %q", got) + } +} diff --git a/cmd/vip-next/commands/logout.go b/cmd/vip-next/commands/logout.go new file mode 100644 index 000000000..111aa9fb9 --- /dev/null +++ b/cmd/vip-next/commands/logout.go @@ -0,0 +1,43 @@ +package commands + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/rechallenge" +) + +// LogoutCmd returns `vip logout`. logout is on the auth-bypass list, so it +// loads and purges vip-next's token itself. Server-side invalidation is +// best-effort and never uses the read-only Node-token fallback; local purge and +// elevated-cache clear always run. +func LogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Log out the current authenticated VIP-CLI user", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := GetConfig() + k := keychain.New(cfg.APIHost) + store := auth.NewStore(k) + + if raw, err := store.LoadPrimary(); err == nil && raw != "" { + _ = auth.PostLogout(cfg.APIHost, raw) + } + if err := store.Delete(); err != nil && !errors.Is(err, auth.ErrNoToken) { + return err + } + elevated := &keychain.Keychain{Backend: k.Backend, Service: rechallenge.ServiceNameForHost(cfg.APIHost)} + _ = (&rechallenge.TokenCache{Keychain: elevated}).ClearAll() + + trackEvent("logout_command_execute", nil) + fmt.Fprintln(cmd.OutOrStdout(), "You are now logged out.") + return nil + }, + } +} diff --git a/cmd/vip-next/commands/logout_test.go b/cmd/vip-next/commands/logout_test.go new file mode 100644 index 000000000..ce4cee44c --- /dev/null +++ b/cmd/vip-next/commands/logout_test.go @@ -0,0 +1,158 @@ +package commands + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" +) + +// memBackendLogout is an in-memory keychain Backend for logout tests. +// Mirrors the same helper used in internal/auth/store_test.go but lives +// in this package so it can be used with commands.Config injection. +type memBackendLogout struct{ store map[string]string } + +func (m *memBackendLogout) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackendLogout) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackendLogout) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +// runLogoutCmd drives LogoutCmd end-to-end with an injected keychain and an +// httptest server acting as the API. It returns the captured stdout and any +// error returned by RunE. +// +// Since LogoutCmd calls keychain.New(cfg.APIHost) internally — which picks the +// OS keyring, or the 0600 file fallback on a headless box — we cannot intercept +// that call without modifying the command's signature. Instead, we: +// 1. Set cfg.APIHost to the test server URL. The command calls keychain.New +// with that URL, which creates a Keychain with a real backend. On +// Delete that returns ErrNotFound (no token stored under the test +// service name), which the command swallows (Node parity: logout is +// idempotent). +// 2. We verify token-purge behaviour by directly exercising auth.Store with +// a memBackend — that path is already covered in internal/auth/store_test.go. +// 3. We assert: command exits 0, stdout contains the success message, and +// PostLogout is called with the correct bearer token. +func runLogoutCmd(t *testing.T, srv *httptest.Server) (string, error) { + t.Helper() + SetConfig(Config{APIHost: srv.URL}) + defer SetConfig(Config{}) + + cmd := LogoutCmd() + var out bytes.Buffer + cmd.SetOut(&out) + err := cmd.RunE(cmd, nil) + return out.String(), err +} + +// TestLogoutCmdNoToken verifies that running logout when there is no stored +// token exits 0 (idempotent, Node parity). +func TestLogoutCmdNoToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Should NOT be called when no token is present (store.Load returns error). + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + out, err := runLogoutCmd(t, srv) + if err != nil { + t.Fatalf("LogoutCmd with no token: expected nil error, got %v", err) + } + if !strings.Contains(out, "You are now logged out.") { + t.Errorf("expected logout message in output, got: %q", out) + } +} + +// TestLogoutCmdWithToken verifies that when a token is present (via +// VIP_TOKEN_OVERRIDE), PostLogout is called with the Bearer token and the +// command still exits 0. The actual keychain deletion is tested in +// internal/auth/store_test.go with a memBackend — here we focus on the +// command plumbing: correct HTTP call + success output. +// +// GO_ENV=test is required since cutover item 2.15: the override is a test-only +// hatch (Node gates the same variable on NODE_ENV=test, src/lib/token.ts:105). +func TestLogoutCmdWithToken(t *testing.T) { + const testToken = "test-bearer-token" + + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/logout" && r.Method == http.MethodPost { + gotAuth = r.Header.Get("Authorization") + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + t.Setenv("GO_ENV", "test") + t.Setenv("VIP_TOKEN_OVERRIDE", testToken) + + out, err := runLogoutCmd(t, srv) + if err != nil { + t.Fatalf("LogoutCmd with token: expected nil error, got %v", err) + } + if gotAuth != "Bearer "+testToken { + t.Errorf("PostLogout Authorization = %q, want %q", gotAuth, "Bearer "+testToken) + } + if !strings.Contains(out, "You are now logged out.") { + t.Errorf("expected logout message in output, got: %q", out) + } +} + +// TestLogoutCmdTokenPurge verifies that after logout, the token is gone from +// the store. This is a unit-level test over auth.Store + memBackend — the +// actual end-to-end token path is covered here without touching the OS keychain. +func TestLogoutCmdTokenPurge(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + backend := &memBackendLogout{} + k := &keychain.Keychain{ + Backend: backend, + Service: "vip-next-cli", + LegacyService: "vip-go-cli", + } + store := auth.NewStore(k) + if err := backend.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := store.Save("primary-token"); err != nil { + t.Fatalf("Save: %v", err) + } + + // Verify token is present. + tok, err := store.Load() + if err != nil || tok != "primary-token" { + t.Fatalf("precondition: Load = %q, %v", tok, err) + } + + // Simulate what LogoutCmd does: delete the token. + if err := store.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + + if got := backend.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("legacy token = %q, want unchanged legacy-token", got) + } + if _, err := store.Load(); !errors.Is(err, auth.ErrNoToken) { + t.Fatalf("Load after logout = %v, want ErrNoToken", err) + } +} diff --git a/cmd/vip-next/commands/logs.go b/cmd/vip-next/commands/logs.go new file mode 100644 index 000000000..151cd0c9d --- /dev/null +++ b/cmd/vip-next/commands/logs.go @@ -0,0 +1,169 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/logsapi" + "github.com/Automattic/vip/internal/output" + "github.com/Automattic/vip/internal/polling" +) + +// Node parity constants (src/bin/vip-logs.js): +// +// LIMIT_MIN=1, LIMIT_MAX=5000, LIMIT_DEFAULT=500 +// ALLOWED_TYPES={app, batch} +// ALLOWED_FORMATS={csv, json, table, text} +const ( + logsLimitMin = 1 + logsLimitMax = 5000 + logsLimitDefault = 500 +) + +var ( + logsAllowedTypes = []string{"app", "batch"} + logsAllowedFormats = []string{"table", "csv", "json", "text"} +) + +// LogsCmd returns `vip logs`. Wraps logsapi.RecentLogs for one-shot fetches +// and threads --follow through internal/polling.Loop (server-hinted backoff +// with min/max clamping). Node parity: +// - "Invalid type/limit/format" error wording matches src/bin/vip-logs.js. +// - Empty result prints "No logs found" to stderr + exit 0. +// - Tab characters in messages are replaced with 4 spaces for table format +// output (Node's printLogs: message.replace(/\t/g, ' ')). +func LogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "Retrieve runtime logs for an environment", + Long: "Retrieve application or batch runtime logs for a VIP Platform environment.", + } + // vip-logs.js registers type/limit/follow/format in that order, so -f + // goes to --follow and --format is left with no short (command.js:62-82). + cmd.Flags().StringP("type", "t", "app", `Type of logs to retrieve. Accepts "app" or "batch".`) + cmd.Flags().IntP("limit", "l", logsLimitDefault, fmt.Sprintf("Maximum number of entries to return (1..%d).", logsLimitMax)) + cmd.Flags().BoolP("follow", "f", false, "Output new entries as they are generated.") + return buildAppEnvRenderableCmd(cmd, "table", logsAllowedFormats, runLogs) +} + +func runLogs(cmd *cobra.Command, args []string) (any, error) { + logType, _ := cmd.Flags().GetString("type") + limit, _ := cmd.Flags().GetInt("limit") + follow, _ := cmd.Flags().GetBool("follow") + format, _ := cmd.Flags().GetString("format") + if format == "" { + format = "table" + } + if err := validateLogsInputs(logType, limit); err != nil { + return nil, err + } + + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + trackEvent("logs_command_execute", map[string]any{"type": logType, "limit": limit, "follow": follow, "format": format}) + + if follow { + // Follow mode bypasses WithFormat's terminal Render call (the loop + // renders each page itself). Signal that by returning (nil, err) + // directly from the wrapped handler — output.Render is a no-op on + // nil data. + err := polling.Loop(cmd.Context(), polling.Opts{ + InitialLimit: limit, + FollowLimit: logsLimitMax, + DefaultInterval: 30 * time.Second, + ServerHintMin: 5 * time.Second, + ServerHintMax: 5 * time.Minute, + }, func(ctx context.Context, after *string, fetchLimit int) (polling.Page, error) { + page, ferr := logsapi.RecentLogs(ctx, cfg.GQLClient, ae.App.ID, ae.Env.ID, logType, fetchLimit, after) + if ferr != nil { + return polling.Page{}, ferr + } + rendered := func() error { + return renderLogsPage(cmd.OutOrStdout(), output.Format(format), page.Nodes) + } + return polling.Page{ + Render: rendered, + NextCursor: page.NextCursor, + PollingDelaySecs: page.PollingDelaySeconds, + }, nil + }) + return nil, err + } + + page, err := logsapi.RecentLogs(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, logType, limit, nil) + if err != nil { + trackEvent("logs_command_error", map[string]any{"error": err.Error()}) + return nil, err + } + trackEvent("logs_command_success", map[string]any{"total": len(page.Nodes)}) + if len(page.Nodes) == 0 { + fmt.Fprintln(os.Stderr, "No logs found") + return nil, nil + } + return logRowsFromNodes(page.Nodes), nil +} + +// validateLogsInputs ports Node's vip-logs.js validateInputs. Note that the +// format check is performed by appctx.WithFormat with the same wording, so +// this layer only handles type/limit. We keep both checks in Node's order +// (type, then limit) — but WithFormat wraps this handler, so format errors +// surface from the outer layer. +func validateLogsInputs(logType string, limit int) error { + if !containsStr(logsAllowedTypes, logType) { + return fmt.Errorf("Invalid type: %s. The supported types are: %s.", + logType, strings.Join(logsAllowedTypes, ", ")) + } + if limit < logsLimitMin || limit > logsLimitMax { + return fmt.Errorf("Invalid limit: %d. Set the limit to an integer between %d and %d.", + limit, logsLimitMin, logsLimitMax) + } + return nil +} + +func containsStr(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + +// logRowsFromNodes flattens LogNodes into OrderedRows for output.Render. +// Node parity: message.replace(/\t/g, ' ') in printLogs. This lives at +// the row-builder layer so all formats benefit (Node only does it for the +// table format; we apply it uniformly for simplicity, which is a safe +// extension — tabs in log lines are rare and a 4-space substitute renders +// the same in every format). +func logRowsFromNodes(nodes []logsapi.LogNode) output.OrderedRows { + rows := make(output.OrderedRows, 0, len(nodes)) + for _, n := range nodes { + msg := strings.ReplaceAll(n.Message, "\t", " ") + rows = append(rows, output.OrderedRow{ + {Key: "timestamp", Value: n.Timestamp}, + {Key: "message", Value: msg}, + }) + } + return rows +} + +// renderLogsPage is called inside the polling loop to render a single +// page's worth of nodes. Empty pages render nothing (Node parity: +// printLogs is only called when nodes.length > 0 in the follow path). +func renderLogsPage(w io.Writer, f output.Format, nodes []logsapi.LogNode) error { + if len(nodes) == 0 { + return nil + } + return output.Render(w, f, logRowsFromNodes(nodes)) +} diff --git a/cmd/vip-next/commands/logs_test.go b/cmd/vip-next/commands/logs_test.go new file mode 100644 index 000000000..c8e2dc34c --- /dev/null +++ b/cmd/vip-next/commands/logs_test.go @@ -0,0 +1,174 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/output" +) + +// logsStubServer returns a single-response GraphQL stub. The handlers fire +// one query per invocation (RecentLogs), so a constant body is enough. +func logsStubServer(_ *testing.T, body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +// setupLogsConfig wires SetConfig with a genqlient client pointed at srv. +// Tests bypass the WithAppContext + WithEnvContext middleware (handler is +// invoked directly), so we leave Tracker + AppCtxConfig zero. +func setupLogsConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: c}) +} + +// logsCtx returns a context carrying a pre-resolved AppEnv. The handler +// reads App.ID + Env.ID; everything else can stay zero. +func logsCtx(appID, envID int64) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "x"}, + Env: appctx.Env{ID: envID, Name: "develop"}, + }) +} + +func TestValidateLogsInputsOK(t *testing.T) { + cases := []struct { + typ string + limit int + }{ + {"app", 1}, + {"app", 500}, + {"app", 5000}, + {"batch", 100}, + } + for _, tc := range cases { + if err := validateLogsInputs(tc.typ, tc.limit); err != nil { + t.Errorf("validateLogsInputs(%q, %d) = %v, want nil", tc.typ, tc.limit, err) + } + } +} + +func TestValidateLogsInputsBadType(t *testing.T) { + err := validateLogsInputs("unknown", 500) + if err == nil { + t.Fatal("validateLogsInputs(unknown, 500) = nil, want error") + } + want := "Invalid type: unknown. The supported types are: app, batch." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +func TestValidateLogsInputsLimitTooLow(t *testing.T) { + err := validateLogsInputs("app", 0) + if err == nil { + t.Fatal("validateLogsInputs(app, 0) = nil, want error") + } + want := "Invalid limit: 0. Set the limit to an integer between 1 and 5000." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +func TestValidateLogsInputsLimitTooHigh(t *testing.T) { + err := validateLogsInputs("app", 5001) + if err == nil { + t.Fatal("validateLogsInputs(app, 5001) = nil, want error") + } + want := "Invalid limit: 5001. Set the limit to an integer between 1 and 5000." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +func TestRunLogsHappyPathReturnsRows(t *testing.T) { + srv := logsStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","message":"hello"},{"timestamp":"2024-01-01T00:00:01Z","message":"line\twith\ttabs"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + setupLogsConfig(srv) + defer SetConfig(Config{}) + + cmd := LogsCmd() + cmd.SetContext(logsCtx(1, 2)) + + data, err := runLogs(cmd, nil) + if err != nil { + t.Fatalf("runLogs: %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want output.OrderedRows", data) + } + if len(rows) != 2 { + t.Fatalf("rows len = %d, want 2", len(rows)) + } + if rows[0][0].Key != "timestamp" || rows[0][0].Value.(string) != "2024-01-01T00:00:00Z" { + t.Errorf("row[0][0] = %+v, want timestamp=2024-01-01T00:00:00Z", rows[0][0]) + } + if rows[0][1].Key != "message" || rows[0][1].Value.(string) != "hello" { + t.Errorf("row[0][1] = %+v, want message=hello", rows[0][1]) + } + // Verify tab → 4-space normalization for table parity with Node. + if got := rows[1][1].Value.(string); got != "line with tabs" { + t.Errorf("row[1] message = %q, want %q (tab→4-space normalization)", got, "line with tabs") + } +} + +func TestRunLogsEmptyWritesStderrAndReturnsNil(t *testing.T) { + srv := logsStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + setupLogsConfig(srv) + defer SetConfig(Config{}) + + // Redirect os.Stderr to capture the "No logs found" message — Node uses + // console.error for this, which we mirror by writing to os.Stderr. + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cmd := LogsCmd() + cmd.SetContext(logsCtx(1, 2)) + + data, err := runLogs(cmd, nil) + if err != nil { + t.Fatalf("runLogs: %v", err) + } + if data != nil { + t.Errorf("data = %+v, want nil for empty-result case", data) + } + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + if !strings.Contains(buf.String(), "No logs found") { + t.Errorf("stderr = %q, want 'No logs found'", buf.String()) + } +} + +func TestRunLogsRejectsBadType(t *testing.T) { + srv := logsStubServer(t, `{}`) + defer srv.Close() + setupLogsConfig(srv) + defer SetConfig(Config{}) + + cmd := LogsCmd() + _ = cmd.Flags().Set("type", "nope") + cmd.SetContext(logsCtx(1, 2)) + + _, err := runLogs(cmd, nil) + if err == nil { + t.Fatal("runLogs: expected error for bad type") + } + if !strings.Contains(err.Error(), "Invalid type: nope") { + t.Errorf("err = %v, want Invalid type: nope", err) + } +} diff --git a/cmd/vip-next/commands/nodeflag_aliases.go b/cmd/vip-next/commands/nodeflag_aliases.go new file mode 100644 index 000000000..f0dd95fed --- /dev/null +++ b/cmd/vip-next/commands/nodeflag_aliases.go @@ -0,0 +1,59 @@ +package commands + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// addFormatFlagWithShort pre-registers --format with the -f short for the +// commands where Node's factory registers it before any bin option and it +// therefore wins the auto-derived 'f' (src/lib/cli/command.js:1090-1095). +// appctx.WithFormat only registers the flag when it is absent, so calling +// this first is enough. Commands where a bin option claims 'f' first +// (`vip logs --follow`) must NOT call this. +func addFormatFlagWithShort(c *cobra.Command) { + if c.Flags().Lookup("format") == nil { + c.Flags().StringP("format", "f", "table", "Render output in a particular format.") + } +} + +// aliasFlagName makes `--<from>` resolve to the already-registered `--<to>` at +// parse time. pflag normalizes a long flag name before looking it up +// (FlagSet.parseLongArg), so this is a true alias — one flag, two spellings — +// rather than a second flag whose value has to be merged. +// +// Shorthands are not affected: pflag looks those up in a separate table, so +// the short alias must be declared on the canonical flag itself. +func aliasFlagName(c *cobra.Command, from, to string) { + prev := c.Flags().GetNormalizeFunc() + c.Flags().SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName { + if name == from { + return pflag.NormalizedName(to) + } + return prev(f, name) + }) +} + +// addSkipConfirmationWithForceAlias registers the confirmation bypass for the +// commands whose gate came from Node's `requireConfirm` +// (src/lib/cli/command.js:1086-1088): `vip sync`, `vip import media` and +// `vip import media abort`. Node spells that flag `--force` and, because it is +// registered before the bin's own options, gives it the short `-f`. +// +// vip-next renamed it `--skip-confirmation`. The rename stands (it is the name +// used everywhere else in the Go tree), but Node's spelling and short must +// keep working or every existing script that passes `--force` fails at parse +// time. Call this BEFORE appctx.WithSkipConfirmationFlag, which is a no-op +// once the flag exists. +// +// NOTE: this deliberately does NOT resurrect Node's `--force=false` bug. In +// Node --force is a commander boolean, so `--force=false` is not recognized as +// a value form and the truthy string leaks through, SKIPPING the prompt. +// vip-next parses it as a real bool, so `--force=false` still prompts. +func addSkipConfirmationWithForceAlias(c *cobra.Command) { + if c.Flags().Lookup("skip-confirmation") == nil { + c.PersistentFlags().BoolP("skip-confirmation", "f", false, "Skip the confirmation prompt.") + c.Flags().AddFlagSet(c.PersistentFlags()) + } + aliasFlagName(c, "force", "skip-confirmation") +} diff --git a/cmd/vip-next/commands/progress_renderer.go b/cmd/vip-next/commands/progress_renderer.go new file mode 100644 index 000000000..e2d03cf9b --- /dev/null +++ b/cmd/vip-next/commands/progress_renderer.go @@ -0,0 +1,118 @@ +package commands + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Automattic/vip/internal/tui" +) + +// frameSource is anything that can render its current state as a +// multi-line frame. Implemented by *tui.ProgressTracker (imports, backup, +// deploy, export, dev-env sync) and *mediaimport.Tracker (import media). +type frameSource interface { + Frame() string +} + +// importProgressRenderer ticks the tracker frame onto stderr on a TTY +// (PRINT_INTERVAL = 200ms; 5000ms under --debug — progress.ts:6). On +// non-TTY nothing is animated; the final frame prints once at the end. +type importProgressRenderer struct { + src frameSource + renderer *tui.MultiLineRenderer + done chan struct{} + loopDone sync.WaitGroup // signals the ticker goroutine has fully exited + stopped bool +} + +func startImportProgressRenderer(cmd *cobra.Command, src frameSource) *importProgressRenderer { + return startProgressRenderer(cmd, src, os.Stderr, term.IsTerminal(int(os.Stderr.Fd()))) +} + +// startBackupProgressRenderer keeps progress and the success message on +// stdout, matching Node's progress.ts + backup-db.ts stream contract. Other +// heavy commands retain their existing TTY-on-stderr policy. +func startBackupProgressRenderer(cmd *cobra.Command, src frameSource) *importProgressRenderer { + out := cmd.OutOrStdout() + tty := false + if f, ok := out.(interface{ Fd() uintptr }); ok { + tty = term.IsTerminal(int(f.Fd())) + } + return startProgressRenderer(cmd, src, out, tty) +} + +func startProgressRenderer(cmd *cobra.Command, src frameSource, animatedOut io.Writer, tty bool) *importProgressRenderer { + r := &importProgressRenderer{src: src, done: make(chan struct{})} + if !tty { + return r + } + r.renderer = tui.NewMultiLineRenderer(animatedOut, true) + interval := 200 * time.Millisecond + if f := cmd.Flag("debug"); f != nil && f.Changed { + interval = 5 * time.Second + } + r.loopDone.Add(1) + go func() { + defer r.loopDone.Done() + tk := time.NewTicker(interval) + defer tk.Stop() + for { + select { + case <-r.done: + return + case <-tk.C: + r.renderer.Render(strings.Split(strings.TrimRight(r.src.Frame(), "\n"), "\n")) + } + } + }() + return r +} + +// stop halts the ticker. final=true renders one last frame (TTY) or — on +// non-TTY — prints the frame once to stdout so scripted runs still see +// the terminal state (the sync non-TTY precedent: render on transition +// only; here the single final frame is the stable output). +func (r *importProgressRenderer) stop(cmd *cobra.Command, final bool) { + r.stopWithTrailingBlank(cmd, final, true) +} + +// stopCompact is the backup-db variant: Node prints the success message on +// the line immediately after the final progress frame, without the blank line +// used by import command framing. +func (r *importProgressRenderer) stopCompact(cmd *cobra.Command, final bool) { + r.stopWithTrailingBlank(cmd, final, false) +} + +func (r *importProgressRenderer) stopWithTrailingBlank(cmd *cobra.Command, final, trailingBlank bool) { + if r.stopped { + return + } + r.stopped = true + close(r.done) + if r.renderer != nil { + // Wait for the ticker goroutine to fully exit before the final + // frame: otherwise it could be mid-Render (a data race on the + // shared renderer) and its stray frame would land below ours, + // duplicating the top line. Mirrors ttyRenderer (sync_render.go). + r.loopDone.Wait() + if final { + r.renderer.Render(strings.Split(strings.TrimRight(r.src.Frame(), "\n"), "\n")) + } + r.renderer.Done() + return + } + if final { + frame := r.src.Frame() + fmt.Fprint(cmd.OutOrStdout(), frame) + if trailingBlank || !strings.HasSuffix(frame, "\n") { + fmt.Fprintln(cmd.OutOrStdout()) + } + } +} diff --git a/cmd/vip-next/commands/progress_renderer_test.go b/cmd/vip-next/commands/progress_renderer_test.go new file mode 100644 index 000000000..769963397 --- /dev/null +++ b/cmd/vip-next/commands/progress_renderer_test.go @@ -0,0 +1,28 @@ +package commands + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +type fixedFrame string + +func (f fixedFrame) Frame() string { return string(f) } + +func TestBackupTTYProgressAndSuccessShareStdout(t *testing.T) { + cmd := &cobra.Command{} + var stdout bytes.Buffer + cmd.SetOut(&stdout) + + renderer := startProgressRenderer(cmd, fixedFrame("✓ Generating backup \n"), cmd.OutOrStdout(), true) + renderer.stopCompact(cmd, true) + fmt.Fprintln(cmd.OutOrStdout(), "New database backup created") + + if got := stdout.String(); !strings.Contains(got, "✓ Generating backup \nNew database backup created\n") { + t.Fatalf("TTY progress and success must be ordered on stdout; got %q", got) + } +} diff --git a/cmd/vip-next/commands/searchreplace.go b/cmd/vip-next/commands/searchreplace.go new file mode 100644 index 000000000..acde458ad --- /dev/null +++ b/cmd/vip-next/commands/searchreplace.go @@ -0,0 +1,73 @@ +package commands + +import ( + "errors" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/searchreplace" +) + +// SearchReplaceCmd returns `vip search-replace <file>`. Node parity: +// src/bin/vip-search-replace.js. Reuses internal/searchreplace.Run (built for +// import sql). Unlike the import path, standalone defaults output to STDOUT. +func SearchReplaceCmd() *cobra.Command { + var pairs []string + var inPlace bool + var output string + cmd := &cobra.Command{ + Use: "search-replace <file>", + Short: "Search and replace strings in a local file", + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 || args[0] == "" { + return errors.New("You must pass in a filename") + } + if len(pairs) == 0 { + return errors.New("You must provide a pair of strings (separated by comma) such as original,replacement") + } + // Node prompts before an in-place rewrite and defaults to No + // (search-and-replace.ts:151); the standalone bin passes no + // batchMode (vip-search-replace.js:74), so this path always asks. + // Declining exits 0 with the file untouched, matching Node's bare + // process.exit(). A context that cannot prompt is refused outright + // rather than proceeding silently or hanging. + if inPlace { + approved, err := appctx.Confirm(cmd, searchreplace.InPlaceConfirmMessage, false) + if err != nil { + return err + } + if !approved { + return nil + } + } + res, err := searchreplace.Run(args[0], pairs, searchreplace.Options{InPlace: inPlace, Output: output}) + if err != nil { + return err + } + // Stdout default: neither --in-place nor --output → stream the result + // file to stdout and remove the temp file (Node: "output to STDOUT by + // default"). + if !inPlace && output == "" { + f, err := os.Open(res.OutputFileName) + if err != nil { + return err + } + defer f.Close() + defer os.Remove(res.OutputFileName) + if _, err := io.Copy(cmd.OutOrStdout(), f); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().StringArrayVarP(&pairs, "search-replace", "s", nil, `A comma-separated pair of strings (e.g. --search-replace="from,to").`) + cmd.Flags().BoolVarP(&inPlace, "in-place", "i", false, "Overwrite the local input file with the results.") + cmd.Flags().StringVarP(&output, "output", "o", "", "Local file path to save the results (ignored with --in-place).") + return cmd +} diff --git a/cmd/vip-next/commands/searchreplace_test.go b/cmd/vip-next/commands/searchreplace_test.go new file mode 100644 index 000000000..6b0b37a35 --- /dev/null +++ b/cmd/vip-next/commands/searchreplace_test.go @@ -0,0 +1,155 @@ +package commands + +import ( + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// fakeSearchReplaceBinary writes a shell stand-in for go-search-replace that +// upper-cases stdin (mirrors internal/searchreplace test harness). +func fakeSearchReplaceBinary(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("go-search-replace stand-in is a POSIX #!/bin/sh script; not executable on Windows") + } + p := filepath.Join(t.TempDir(), "go-search-replace") + if err := os.WriteFile(p, []byte("#!/bin/sh\ntr 'a-z' 'A-Z'\n"), 0o755); err != nil { // #nosec G306 -- executable test script + t.Fatal(err) + } + return p +} + +func TestSearchReplaceMissingFilename(t *testing.T) { + cmd := SearchReplaceCmd() + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "You must pass in a filename") { + t.Errorf("err = %v", err) + } +} + +func TestSearchReplaceMissingPairs(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "in.sql") + _ = os.WriteFile(f, []byte("x"), 0o644) + cmd := SearchReplaceCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{f}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "You must provide a pair of strings") { + t.Errorf("err = %v", err) + } +} + +func TestSearchReplaceStdoutDefault(t *testing.T) { + bin := fakeSearchReplaceBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + dir := t.TempDir() + f := filepath.Join(dir, "in.sql") + _ = os.WriteFile(f, []byte("hello from\n"), 0o644) + + var out bytes.Buffer + cmd := SearchReplaceCmd() + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{f, "--search-replace=from,to"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + // The fake binary upper-cases; default output goes to stdout. + if !strings.Contains(out.String(), "HELLO FROM") { + t.Errorf("stdout default not honored: %q", out.String()) + } +} + +// Parity blocker B2, second half. Node prompts "Are you sure you want to run +// search and replace on your input file? This operation is not reversible." +// and defaults to No (search-and-replace.ts:151-155) whenever inPlace is set +// and batchMode is not — and the standalone bin never passes batchMode +// (vip-search-replace.js:74). vip-next rewrote the file with no prompt at all. +// +// The test process has no TTY, so the confirm cannot be answered: the command +// must refuse and leave the file byte-for-byte intact. Asserting the exit code +// alone would not have caught the old behavior — assert the bytes. +func TestSearchReplaceInPlaceRefusesWithoutConfirmation(t *testing.T) { + bin := fakeSearchReplaceBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + dir := t.TempDir() + f := filepath.Join(dir, "in.sql") + const original = "hello from\n" + if err := os.WriteFile(f, []byte(original), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + cmd := SearchReplaceCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{f, "--search-replace=from,to", "--in-place"}) + if err := cmd.Execute(); err == nil { + t.Error("expected --in-place to refuse when the confirmation cannot be shown") + } + + got, err := os.ReadFile(f) // #nosec G304 + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Errorf("file was rewritten without confirmation:\n got %q\nwant %q", got, original) + } +} + +// VIP_NON_INTERACTIVE must not hang or silently proceed either. +func TestSearchReplaceInPlaceRefusesWhenNonInteractive(t *testing.T) { + bin := fakeSearchReplaceBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + t.Setenv("VIP_NON_INTERACTIVE", "1") + dir := t.TempDir() + f := filepath.Join(dir, "in.sql") + const original = "hello from\n" + if err := os.WriteFile(f, []byte(original), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + cmd := SearchReplaceCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{f, "--search-replace=from,to", "--in-place"}) + if err := cmd.Execute(); err == nil { + t.Error("expected --in-place to refuse under VIP_NON_INTERACTIVE") + } + got, _ := os.ReadFile(f) // #nosec G304 + if string(got) != original { + t.Errorf("file was rewritten under VIP_NON_INTERACTIVE: %q", got) + } +} + +// The confirm is specific to --in-place: --output writes somewhere else, so +// Node never prompts and neither may we. +func TestSearchReplaceOutputFile(t *testing.T) { + bin := fakeSearchReplaceBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + dir := t.TempDir() + f := filepath.Join(dir, "in.sql") + outPath := filepath.Join(dir, "out.sql") + _ = os.WriteFile(f, []byte("abc\n"), 0o644) + + cmd := SearchReplaceCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{f, "--search-replace=a,b", "--output=" + outPath}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(outPath) + if err != nil || !strings.Contains(string(got), "ABC") { + t.Errorf("output file = %q err=%v", got, err) + } +} diff --git a/cmd/vip-next/commands/slowlogs.go b/cmd/vip-next/commands/slowlogs.go new file mode 100644 index 000000000..576e69983 --- /dev/null +++ b/cmd/vip-next/commands/slowlogs.go @@ -0,0 +1,167 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/output" + "github.com/Automattic/vip/internal/polling" + "github.com/Automattic/vip/internal/slowlogsapi" +) + +// Node parity constants. THERE ARE TWO `LIMIT_MAX`es AND THEY DIFFER — do +// not collapse them: +// +// - src/lib/app-slowlogs/app-slowlogs.ts:9 `export const LIMIT_MAX = 5000` +// is what validateInputs actually gates --limit on +// (vip-slowlogs.ts:167 `limit > slowlogsLib.LIMIT_MAX`). +// - src/bin/vip-slowlogs.ts:21 `const LIMIT_MAX = 500` (module-local) is +// referenced ONLY by followLogs' refetch size (line 78 +// `const limit = isFirstRequest ? opt.limit : LIMIT_MAX`). +// +// Node's own --help copy quotes the wrong one ("Accepts an integer value +// between 1 and 500", vip-slowlogs.ts:201). The code wins: 1..5000 is +// accepted. The help string below reproduces Node's wording verbatim, +// including that inaccuracy, because it is user-visible cutover surface. +// +// ALLOWED_FORMATS={csv, json, table} — NO text format, unlike vip logs. +const ( + slowlogsLimitMin = 1 + // slowlogsValidationMax = slowlogsLib.LIMIT_MAX (app-slowlogs.ts:9). + slowlogsValidationMax = 5000 + // slowlogsFollowLimit = the module-local LIMIT_MAX (vip-slowlogs.ts:21). + slowlogsFollowLimit = 500 + slowlogsLimitDefault = 500 +) + +var slowlogsAllowedFormats = []string{"table", "csv", "json"} + +// SlowlogsCmd returns `vip slowlogs`. Mirrors LogsCmd's shape — same +// 1..5000 limit ceiling — with the slowlog row schema +// (timestamp, rowsSent, rowsExamined, queryTime, requestUri, query), +// and no `text` format (Node parity — vip-slowlogs.ts uses formatData +// only). +// +// Note: Node's vip-slowlogs.ts declares `followLogs` but does not expose +// a `--follow` flag. We add it as a Go-side extension because the +// polling primitive is the same as `vip logs` and there's no downside +// to surfacing it — `getRecentSlowlogs` already accepts an `after` +// cursor in the Node lib. +func SlowlogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "slowlogs", + Short: "Retrieve MySQL slow-query logs for an environment", + Long: "Retrieve MySQL slow-query log entries for a VIP Platform environment.", + } + // vip-slowlogs.ts sets format:true in the factory, so --format takes -f + // and --limit takes -l. (--follow is vip-next-only: Node never registers + // it on slowlogs, even though followLogs exists in the lib.) + cmd.Flags().StringP("format", "f", "table", + "Render output in a particular format.") + // Verbatim Node copy (vip-slowlogs.ts:201). It says 500; the validator + // accepts up to 5000. See the constant block above. + cmd.Flags().IntP("limit", "l", slowlogsLimitDefault, + "Set the maximum number of log entries. Accepts an integer value between 1 and 500.") + cmd.Flags().Bool("follow", false, "Output new entries as they are generated.") + return buildAppEnvRenderableCmd(cmd, "table", slowlogsAllowedFormats, runSlowlogs) +} + +func runSlowlogs(cmd *cobra.Command, args []string) (any, error) { + limit, _ := cmd.Flags().GetInt("limit") + follow, _ := cmd.Flags().GetBool("follow") + format, _ := cmd.Flags().GetString("format") + if format == "" { + format = "table" + } + if err := validateSlowlogsInputs(limit); err != nil { + return nil, err + } + + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + trackEvent("slowlogs_command_execute", map[string]any{"limit": limit, "follow": follow, "format": format}) + + if follow { + err := polling.Loop(cmd.Context(), polling.Opts{ + InitialLimit: limit, + FollowLimit: slowlogsFollowLimit, + DefaultInterval: 30 * time.Second, + ServerHintMin: 5 * time.Second, + ServerHintMax: 5 * time.Minute, + }, func(ctx context.Context, after *string, fetchLimit int) (polling.Page, error) { + page, ferr := slowlogsapi.RecentSlowlogs(ctx, cfg.GQLClient, ae.App.ID, ae.Env.ID, fetchLimit, after) + if ferr != nil { + return polling.Page{}, ferr + } + rendered := func() error { + return renderSlowlogsPage(cmd.OutOrStdout(), output.Format(format), page.Nodes) + } + return polling.Page{ + Render: rendered, + NextCursor: page.NextCursor, + PollingDelaySecs: page.PollingDelaySeconds, + }, nil + }) + return nil, err + } + + page, err := slowlogsapi.RecentSlowlogs(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID, limit, nil) + if err != nil { + trackEvent("slowlogs_command_error", map[string]any{"error": err.Error()}) + return nil, err + } + trackEvent("slowlogs_command_success", map[string]any{"total": len(page.Nodes)}) + if len(page.Nodes) == 0 { + // Node parity: vip-slowlogs.ts also prints 'No logs found' (yes, the + // same wording as vip-logs) to console.error and returns. + fmt.Fprintln(os.Stderr, "No logs found") + return nil, nil + } + return slowlogRowsFromNodes(page.Nodes), nil +} + +// validateSlowlogsInputs ports Node's vip-slowlogs.ts validateInputs. +// Format validation is delegated to appctx.WithFormat (same wording). +func validateSlowlogsInputs(limit int) error { + if limit < slowlogsLimitMin || limit > slowlogsValidationMax { + return fmt.Errorf("Invalid limit: %d. Set the limit to an integer between %d and %d.", + limit, slowlogsLimitMin, slowlogsValidationMax) + } + return nil +} + +// slowlogRowsFromNodes flattens SlowlogNodes into OrderedRows. Column +// ordering matches Node's printSlowlogs destructuring: +// +// { timestamp, rowsSent, rowsExamined, queryTime, requestUri, query }. +func slowlogRowsFromNodes(nodes []slowlogsapi.SlowlogNode) output.OrderedRows { + rows := make(output.OrderedRows, 0, len(nodes)) + for _, n := range nodes { + rows = append(rows, output.OrderedRow{ + {Key: "timestamp", Value: n.Timestamp}, + {Key: "rowsSent", Value: n.RowsSent}, + {Key: "rowsExamined", Value: n.RowsExamined}, + {Key: "queryTime", Value: n.QueryTime}, + {Key: "requestUri", Value: n.RequestUri}, + {Key: "query", Value: n.Query}, + }) + } + return rows +} + +func renderSlowlogsPage(w io.Writer, f output.Format, nodes []slowlogsapi.SlowlogNode) error { + if len(nodes) == 0 { + return nil + } + return output.Render(w, f, slowlogRowsFromNodes(nodes)) +} diff --git a/cmd/vip-next/commands/slowlogs_test.go b/cmd/vip-next/commands/slowlogs_test.go new file mode 100644 index 000000000..b0e50885c --- /dev/null +++ b/cmd/vip-next/commands/slowlogs_test.go @@ -0,0 +1,159 @@ +package commands + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/output" +) + +// slowlogsStubServer + setupSlowlogsConfig mirror the logs_test helpers. +func slowlogsStubServer(_ *testing.T, body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func setupSlowlogsConfig(srv *httptest.Server) { + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: c}) +} + +func slowlogsCtx(appID, envID int64) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "x"}, + Env: appctx.Env{ID: envID, Name: "develop"}, + }) +} + +func TestValidateSlowlogsInputsOK(t *testing.T) { + for _, lim := range []int{1, 250, 500} { + if err := validateSlowlogsInputs(lim); err != nil { + t.Errorf("validateSlowlogsInputs(%d) = %v, want nil", lim, err) + } + } +} + +// Register 2.19. Node's validateInputs (src/bin/vip-slowlogs.ts:167) gates +// --limit on `slowlogsLib.LIMIT_MAX`, i.e. the value exported by +// src/lib/app-slowlogs/app-slowlogs.ts:9 — which is 5000. The module-local +// `LIMIT_MAX = 500` (vip-slowlogs.ts:21) is referenced ONLY by followLogs' +// refetch (line 78) and by the (wrong) --help copy. Anything in 501..5000 +// must be accepted. +func TestValidateSlowlogsInputsAcceptsAboveLocalFollowCeiling(t *testing.T) { + for _, lim := range []int{501, 1000, 5000} { + if err := validateSlowlogsInputs(lim); err != nil { + t.Errorf("validateSlowlogsInputs(%d) = %v, want nil (Node ceiling is slowlogsLib.LIMIT_MAX=5000)", lim, err) + } + } +} + +func TestValidateSlowlogsInputsLimitTooLow(t *testing.T) { + err := validateSlowlogsInputs(0) + if err == nil { + t.Fatal("validateSlowlogsInputs(0) = nil, want error") + } + want := "Invalid limit: 0. Set the limit to an integer between 1 and 5000." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +func TestValidateSlowlogsInputsLimitTooHigh(t *testing.T) { + err := validateSlowlogsInputs(5001) + if err == nil { + t.Fatal("validateSlowlogsInputs(5001) = nil, want error") + } + want := "Invalid limit: 5001. Set the limit to an integer between 1 and 5000." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } +} + +// The module-local LIMIT_MAX=500 survives as the follow-mode refetch size +// (vip-slowlogs.ts:78 `const limit = isFirstRequest ? opt.limit : LIMIT_MAX`). +// It must NOT be conflated with the validation ceiling. +func TestSlowlogsFollowRefetchLimitIsLocalLimitMax(t *testing.T) { + if slowlogsFollowLimit != 500 { + t.Errorf("slowlogsFollowLimit = %d, want 500 (vip-slowlogs.ts:21 LIMIT_MAX)", slowlogsFollowLimit) + } + if slowlogsValidationMax == slowlogsFollowLimit { + t.Error("validation ceiling must not be the follow-refetch limit — that is the 2.19 bug") + } +} + +func TestRunSlowlogsHappyPathReturnsRows(t *testing.T) { + srv := slowlogsStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","rowsSent":"10","rowsExamined":"1000","queryTime":"1.234","requestUri":"/wp-admin/edit.php","query":"SELECT 1"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + setupSlowlogsConfig(srv) + defer SetConfig(Config{}) + + cmd := SlowlogsCmd() + cmd.SetContext(slowlogsCtx(1, 2)) + + data, err := runSlowlogs(cmd, nil) + if err != nil { + t.Fatalf("runSlowlogs: %v", err) + } + rows, ok := data.(output.OrderedRows) + if !ok { + t.Fatalf("data type = %T, want output.OrderedRows", data) + } + if len(rows) != 1 { + t.Fatalf("rows len = %d, want 1", len(rows)) + } + // Column ordering must match Node's printSlowlogs destructure: + // timestamp, rowsSent, rowsExamined, queryTime, requestUri, query. + wantKeys := []string{"timestamp", "rowsSent", "rowsExamined", "queryTime", "requestUri", "query"} + if len(rows[0]) != len(wantKeys) { + t.Fatalf("row[0] columns = %d, want %d", len(rows[0]), len(wantKeys)) + } + for i, k := range wantKeys { + if rows[0][i].Key != k { + t.Errorf("row[0][%d].Key = %q, want %q", i, rows[0][i].Key, k) + } + } + if rows[0][5].Value.(string) != "SELECT 1" { + t.Errorf("row[0][5].Value = %v, want SELECT 1", rows[0][5].Value) + } +} + +func TestRunSlowlogsEmptyWritesStderrAndReturnsNil(t *testing.T) { + srv := slowlogsStubServer(t, `{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + setupSlowlogsConfig(srv) + defer SetConfig(Config{}) + + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + defer func() { os.Stderr = origStderr }() + + cmd := SlowlogsCmd() + cmd.SetContext(slowlogsCtx(1, 2)) + + data, err := runSlowlogs(cmd, nil) + if err != nil { + t.Fatalf("runSlowlogs: %v", err) + } + if data != nil { + t.Errorf("data = %+v, want nil for empty-result case", data) + } + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + // Node parity: same 'No logs found' wording as vip logs (yes, + // "logs" in the slowlogs message — see src/bin/vip-slowlogs.ts). + if !strings.Contains(buf.String(), "No logs found") { + t.Errorf("stderr = %q, want 'No logs found'", buf.String()) + } +} diff --git a/cmd/vip-next/commands/sqlreport.go b/cmd/vip-next/commands/sqlreport.go new file mode 100644 index 000000000..aae01afc7 --- /dev/null +++ b/cmd/vip-next/commands/sqlreport.go @@ -0,0 +1,183 @@ +package commands + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/sqlvalidation" +) + +// Shared SQL-validation report formatting, used by `vip import +// validate-sql` (isImport=false) and `vip import sql`'s preflight +// validation (isImport=true). Mirrors the three Node formatters in +// src/lib/validations/sql.ts plus postValidation's error assembly. + +// wpMultisitePrefixPattern matches `wp_<digits>_*` table names — used by +// the createTable sub-classifier to count multisite tables. Mirrors Node +// sql.ts:223's /^wp_(\d+_)/. The Node regex's capture group is dropped +// here because we only need the boolean match (we never read the capture). +var wpMultisitePrefixPattern = regexp.MustCompile(`^wp_\d+_`) + +// formatCheck returns (errors, warnings, infos, problemsAdded) for one +// check. Mirrors the three Node formatters: +// +// lineNumberCheckFormatter — has results: emit error; otherwise emit +// "✅ <message> was found 0 times." info. +// requiredCheckFormatter — inverted: 0 results emits error; otherwise +// "✅ <message> was found N times." info. createTable additionally +// runs the wp_ prefix sub-classifier — but ONLY when !isImport +// (sql.ts:184). +// infoCheckFormatter — push every result.Text as an info; never +// emits errors. +func formatCheck(c *sqlvalidation.Check, isImport bool) (errs, warns, infos []string, problems int) { + switch c.Formatter { + case sqlvalidation.FormatterLineNumber: + if len(c.Results) == 0 { + infos = append(infos, "✅ "+c.Message+" was found 0 times.") + return + } + problems = 1 + lines := make([]string, len(c.Results)) + for i, r := range c.Results { + lines[i] = strconv.Itoa(r.Line) + } + errs = append(errs, + formatErrorLine(c.Message+" on line(s) "+strings.Join(lines, ", ")+"."), + formatRecLine(c.Recommendation), + "", + ) + return + + case sqlvalidation.FormatterRequired: + if len(c.Results) == 0 { + problems = 1 + errs = append(errs, + formatErrorLine(c.Message+" was not found."), + formatRecLine(c.Recommendation), + "", + ) + return + } + infos = append(infos, fmt.Sprintf("✅ %s was found %d times.", c.Message, len(c.Results))) + if c.Key == "createTable" && !isImport { + // Node sql.ts:182 — wp_ prefix sub-classifier runs only in + // standalone validate mode. + extraErrs, extraInfos, addedProblems := checkTablePrefixes(c.Results) + errs = append(errs, extraErrs...) + infos = append(infos, extraInfos...) + problems += addedProblems + } + return + + case sqlvalidation.FormatterInfo: + for _, r := range c.Results { + if r.Text != "" { + infos = append(infos, r.Text) + } + } + return + } + return +} + +// checkTablePrefixes is Node sql.ts:217's checkTablePrefixes — classifies +// captured CREATE TABLE names into wp_ / wp_<n>_ / non-wp_ buckets. Only +// the non-wp_ bucket produces an error. +func checkTablePrefixes(results []sqlvalidation.CheckResult) (errs, infos []string, problems int) { + var wpTables, notWPTables, wpMultisiteTables []string + for _, r := range results { + name := r.Text + switch { + case wpMultisitePrefixPattern.MatchString(name): + wpMultisiteTables = append(wpMultisiteTables, name) + case strings.HasPrefix(name, "wp_"): + wpTables = append(wpTables, name) + default: + notWPTables = append(notWPTables, name) + } + } + if len(wpTables) > 0 { + infos = append(infos, fmt.Sprintf(" - wp_ prefix tables found: %d ", len(wpTables))) + } + if len(notWPTables) > 0 { + problems = 1 + errs = append(errs, + formatErrorLine("tables without wp_ prefix found: "+strings.Join(notWPTables, ",")), + formatRecLine("Please make sure all table names are prefixed with `wp_`"), + "", + ) + } + if len(wpMultisiteTables) > 0 { + infos = append(infos, fmt.Sprintf(" - wp_n_ prefix tables found: %d ", len(wpMultisiteTables))) + } + return +} + +// findDuplicateTables returns the unique table names that appear more +// than once in the input. Mirrors Node sql.ts:396's findDuplicates flow: +// Node walks a set, deleting entries on first sighting and pushing on +// re-sighting, then de-dupes with `new Set([...])`. +func findDuplicateTables(tableNames []string) []string { + seen := map[string]bool{} + for _, name := range tableNames { + seen[name] = true + } + if len(tableNames) == len(seen) { + return nil + } + counts := map[string]int{} + for _, name := range tableNames { + counts[name]++ + } + out := []string{} + emitted := map[string]bool{} + for _, name := range tableNames { + if counts[name] > 1 && !emitted[name] { + out = append(out, name) + emitted[name] = true + } + } + return out +} + +// formatErrorLine and formatRecLine mirror the chalk color wrappers in +// Node sql.ts:19-29. +func formatErrorLine(msg string) string { + return color.RedString("SQL Error:") + " " + msg +} + +func formatRecLine(msg string) string { + return color.YellowString("Recommendation:") + " " + msg +} + +// buildImportValidationError mirrors postValidation with isImport=true +// (sql.ts:409,494): collect error lines + the bold-red footer into a +// single string the import command surfaces via the thrown-error path. +// Returns ("", 0) when the file is clean. +func buildImportValidationError(res *sqlvalidation.Result) (string, int) { + var errLines []string + problems := 0 + for _, check := range res.Checks { + errs, _, _, p := formatCheck(check, true) + errLines = append(errLines, errs...) + problems += p + } + if dups := findDuplicateTables(res.TableNames); len(dups) > 0 { + problems++ + errLines = append(errLines, + formatErrorLine("Duplicate table names were found: "+strings.Join(dups, ",")), + formatRecLine("Ensure that there are no duplicate tables in your SQL dump"), + "", + ) + } + if problems == 0 { + return "", 0 + } + errLines = append(errLines, color.New(color.FgRed, color.Bold).Sprint( + "SQL validation failed due to "+strconv.Itoa(problems)+" error(s)")) + return strings.Join(errLines, "\n"), problems +} diff --git a/cmd/vip-next/commands/sqlreport_test.go b/cmd/vip-next/commands/sqlreport_test.go new file mode 100644 index 000000000..69d41cfa8 --- /dev/null +++ b/cmd/vip-next/commands/sqlreport_test.go @@ -0,0 +1,66 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/Automattic/vip/internal/sqlvalidation" +) + +func TestBuildImportValidationError(t *testing.T) { + res, err := sqlvalidation.Validate(strings.NewReader( + "DROP TABLE IF EXISTS `wp_posts`;\nCREATE TABLE `notwp_posts` (id INT) ENGINE=MyISAM;\n")) + if err != nil { + t.Fatal(err) + } + msg, problems := buildImportValidationError(res) + if problems == 0 { + t.Fatal("fixture must produce problems") + } + if !strings.Contains(msg, "SQL validation failed due to") { + t.Errorf("msg = %q", msg) + } + // import mode: no wp_-prefix classifier (sql.ts:184), no "Finished + // processing" header (sql.ts:412). + if strings.Contains(msg, "wp_ prefix tables") || strings.Contains(msg, "without wp_ prefix") || + strings.Contains(msg, "Finished processing") { + t.Errorf("import-mode report leaked validate-sql-only output: %q", msg) + } +} + +func TestBuildImportValidationErrorDuplicateTables(t *testing.T) { + res, err := sqlvalidation.Validate(strings.NewReader( + "CREATE TABLE `wp_posts` (id INT);\nCREATE TABLE `wp_posts` (id INT);\n")) + if err != nil { + t.Fatal(err) + } + msg, problems := buildImportValidationError(res) + if problems == 0 || !strings.Contains(msg, "Duplicate table names were found: wp_posts") { + t.Errorf("problems=%d msg=%q", problems, msg) + } +} + +func TestFormatCheckImportModeSkipsTablePrefixClassifier(t *testing.T) { + res, err := sqlvalidation.Validate(strings.NewReader( + "CREATE TABLE `notwp_posts` (id INT);\n")) + if err != nil { + t.Fatal(err) + } + for _, c := range res.Checks { + if c.Key != "createTable" { + continue + } + errsImport, _, _, _ := formatCheck(c, true) + errsStandalone, _, _, _ := formatCheck(c, false) + joinedImport := strings.Join(errsImport, "\n") + joinedStandalone := strings.Join(errsStandalone, "\n") + if strings.Contains(joinedImport, "without wp_ prefix") { + t.Errorf("import mode ran the prefix classifier: %q", joinedImport) + } + if !strings.Contains(joinedStandalone, "without wp_ prefix") { + t.Errorf("standalone mode must run the prefix classifier: %q", joinedStandalone) + } + return + } + t.Fatal("createTable check not found") +} diff --git a/cmd/vip-next/commands/sync.go b/cmd/vip-next/commands/sync.go new file mode 100644 index 000000000..f1d1a9661 --- /dev/null +++ b/cmd/vip-next/commands/sync.go @@ -0,0 +1,189 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + syncpkg "github.com/Automattic/vip/internal/sync" +) + +// SyncCmd returns `vip sync`. +// +// Node parity: src/bin/vip-sync.js. Production targets are rejected by +// WithChildEnvContext; --skip-confirmation (or a "yes" at the +// "Are you sure..." prompt) is required before the mutation fires. +// +// Rendering split: +// - TTY stderr: in-place frame rendering via internal/tui.MultiLineRenderer +// with a Node-parity braille spinner advanced on a background ticker. +// See sync_render.go for the renderer + spinner glue. +// - Non-TTY (CI, pipes, parity scenarios): one per-transition stdout +// line, unchanged from the pre-spinner behavior so the M6 parity +// scenarios keep passing without modification. +// +// VIP_SYNC_INTERVAL_MS overrides the 5s default poll interval. Used by +// the parity scenarios to keep the test wall-clock under a second. +func SyncCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync data from the production environment to a child environment", + Long: "Trigger a data sync from the production environment of an app into one of its child environments " + + "(develop, staging, ...). Production is the source and is therefore not a valid target.", + Args: cobra.NoArgs, + } + + addAppEnvFlags(cmd) + addSkipConfirmationWithForceAlias(cmd) + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithSkipConfirmationFlag(cmd), + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithChildEnvContext(), + appctx.WithRequireConfirm(cmd, "Are you sure you want to sync from production?", syncConfirmPayload), + ).WithRun(runSync) +} + +func runSync(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + + trackEvent("sync_command_execute", nil) + + // Opt the mutation out of the error-middleware's print + Exit(1) + // behavior so we can intercept "Site is already syncing" inline and + // fall through to polling (Node parity). + mutCtx := gql.WithAllowGQLErrors(cmd.Context()) + + syncing := false + if err := syncpkg.Start(mutCtx, cfg.GQLClient, ae.App.ID, ae.Env.ID); err != nil { + var ase syncpkg.AlreadySyncingError + if errors.As(err, &ase) { + syncing = true + trackEvent("sync_command_execute_error", map[string]any{ + "error": "Already syncing: " + err.Error(), + }) + } else { + // Node parity: print "Error: <msg>" and return (exit 0). The + // allowed-errors context bypassed the middleware's auto-print, + // so we replicate it here. + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return nil + } + } + + // Banner — printed regardless of whether the mutation succeeded fresh + // or we joined an existing run. Yellow app name + colored env types. + if syncing { + fmt.Fprintln(out, color.YellowString("Note:"), "A data sync is already running.") + } + fmt.Fprintln(out) + fmt.Fprintf(out, " syncing: %s\n", color.YellowString(ae.App.Name)) + fmt.Fprintf(out, " from: %s\n", formatEnvironment("production")) + fmt.Fprintf(out, " to: %s\n", formatEnvironment(ae.Env.Type)) + + // Poll to terminal state. Rendering surface depends on whether + // stderr is a TTY: in-place frames for humans, per-transition + // stdout lines for CI. Status query also uses the allow-errors + // context so transient server hiccups (e.g. the first call before + // the job materializes) don't kill the process before we retry. + pollCtx := gql.WithAllowGQLErrors(cmd.Context()) + var renderer syncRenderer + if term.IsTerminal(int(os.Stderr.Fd())) { + renderer = newTTYRenderer(os.Stderr) + } else { + renderer = newNonTTYRenderer(out) + } + defer renderer.Stop() + + p, err := syncpkg.Poll(pollCtx, cfg.GQLClient, ae.App.ID, ae.Env.ID, syncpkg.PollOpts{ + Interval: pollInterval(), + OnTransition: renderer.OnTransition, + OnError: func(_ error) bool { + // Treat all Status errors as transient — Node's setInterval + // just keeps ticking and ignores per-poll errors. + return true + }, + }) + // Stop the renderer BEFORE printing the terminal status line so the + // final line lands cleanly below the (now-frozen) frame on TTY and + // the background spinner goroutine is shut down before we move on. + renderer.Stop() + if err != nil { + // Context cancel / deadline: surface to caller (Node exits 0 on + // ^C too, but we honor errors.Is(context.Canceled) silently). + if errors.Is(err, cmd.Context().Err()) { + return nil + } + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return nil + } + + if p == nil { + // No progress payload — surface a generic "finished" message so + // callers see a terminal line. + fmt.Fprintln(out, color.GreenString( + fmt.Sprintf("✓ Data Sync is finished for %s.", ae.App.Name))) + trackEvent("sync_command_success", nil) + return nil + } + + switch p.Status { + case syncpkg.StatusFailed: + trackEvent("sync_command_error", map[string]any{ + "error": "API returned `failed` status", + }) + fmt.Fprintln(out, color.RedString( + fmt.Sprintf("✕ Data Sync is finished for %s.", ae.App.Name))) + default: + // success (or any non-failed terminal) — Node treats the default + // case as success, so we do too. + trackEvent("sync_command_success", nil) + fmt.Fprintln(out, color.GreenString( + fmt.Sprintf("✓ Data Sync is finished for %s.", ae.App.Name))) + } + // Node parity: failed syncs still exit 0 (the message goes to stdout, + // no non-zero return). + return nil +} + +// formatStepLine returns the colored " <mark> <name>" line printed +// per step transition. Mirrors Node's per-status marks. +func formatStepLine(s syncpkg.Step) string { + switch s.Status { + case syncpkg.StatusPending: + return color.New(color.Faint).Sprintf(" ○ %s", s.Name) + case syncpkg.StatusRunning: + return fmt.Sprintf(" %s %s", color.HiBlueString("…"), s.Name) + case syncpkg.StatusSuccess: + return fmt.Sprintf(" %s %s", color.GreenString("✓"), s.Name) + case syncpkg.StatusFailed: + return fmt.Sprintf(" %s %s", color.RedString("✕"), s.Name) + default: + return fmt.Sprintf(" %s %s", color.YellowString("✕"), s.Name) + } +} + +// pollInterval returns the configured polling interval. VIP_SYNC_INTERVAL_MS +// overrides the default to a millisecond value; parity tests set it to a +// few ms to keep wall-clock short. +func pollInterval() time.Duration { + if v := os.Getenv("VIP_SYNC_INTERVAL_MS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Millisecond + } + } + return syncpkg.DefaultInterval +} diff --git a/cmd/vip-next/commands/sync_confirm.go b/cmd/vip-next/commands/sync_confirm.go new file mode 100644 index 000000000..fd9c052f5 --- /dev/null +++ b/cmd/vip-next/commands/sync_confirm.go @@ -0,0 +1,121 @@ +package commands + +import ( + "errors" + "time" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/output" +) + +// syncConfirmPayload is the `case 'sync'` arm of Node's requireConfirm +// switch (src/lib/cli/command.js:913-933). +// +// Node reads options.env.syncPreview, which vip-sync.js pulls in through its +// appQuery. vip-next resolves app/env with a shared query, so the preview is +// fetched here, at exactly the point Node consumes it. +// +// Order of operations is Node's, and it matters: +// 1. canSync false -> exit 1 with the SERVER's first error message, BEFORE +// the prompt and therefore before the destructive mutation. vip-next +// previously never queried syncPreview and fired a sync the server would +// have refused. +// 2. `From backup` row, only when the preview carries a backup. +// 3. `Replacements` row, always — value is "\n" + the table, and Node's +// formatData returns "" for an empty list, so an empty replacement set +// renders as a bare label plus a blank line. +// +// Node-parity quirk (deliberate): this entire arm lives inside +// `if (_opts.requireConfirm && ! options.force)`, so `--skip-confirmation` / +// `--force` skips the canSync guard too. Do NOT hoist the guard out of the +// payload — that would block syncs the Node CLI allows. +func syncConfirmPayload(cmd *cobra.Command, _ []string, message string) ([]output.Tuple, string, error) { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return nil, message, errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + + resp, err := gql.SyncPreview(cmd.Context(), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return nil, message, err + } + + var preview *gql.SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview + if resp != nil && resp.App != nil && len(resp.App.Environments) > 0 && resp.App.Environments[0] != nil { + preview = resp.App.Environments[0].SyncPreview + } + if preview == nil { + // Node destructures options.env.syncPreview unconditionally, so a + // null preview throws a TypeError and dies via the uncaughtException + // handler ("Please contact VIP Support", exit 1). vip-next refuses + // with a readable message instead of crashing — a deliberate + // improvement, and it still fails closed. + return nil, message, errors.New("Could not sync to this environment: the API returned no sync preview") + } + + if preview.CanSync == nil || !*preview.CanSync { + return nil, message, errors.New("Could not sync to this environment: " + firstSyncErrorMessage(preview.Errors)) + } + + var rows []output.Tuple + if b := preview.Backup; b != nil && b.CreatedAt != nil { + rows = append(rows, output.Tuple{Key: "From backup", Value: toUTCString(*b.CreatedAt)}) + } + + replacements := make(output.OrderedRows, 0, len(preview.Replacements)) + for _, r := range preview.Replacements { + if r == nil { + continue + } + replacements = append(replacements, output.OrderedRow{ + {Key: "from", Value: derefString(r.From)}, + {Key: "to", Value: derefString(r.To)}, + }) + } + rows = append(rows, output.Tuple{Key: "Replacements", Value: "\n" + output.TableString(replacements)}) + + return rows, message, nil +} + +// firstSyncErrorMessage mirrors Node's `errors[0].message` — only the first +// validation error is shown. +func firstSyncErrorMessage(errs []*gql.SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError) string { + for _, e := range errs { + if e != nil && e.Message != nil { + return *e.Message + } + } + // Node would throw on errors[0] of an empty array; we degrade to a + // readable reason rather than crashing. + return "the API did not provide a reason" +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// toUTCString ports JavaScript's Date#toUTCString for the "From backup" +// value: "Mon, 21 Jul 2025 10:11:12 GMT". An unparseable input yields +// "Invalid Date", exactly as `new Date('garbage').toUTCString()` does. +func toUTCString(value string) string { + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, l := range layouts { + if t, err := time.Parse(l, value); err == nil { + return t.UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") + } + } + return "Invalid Date" +} diff --git a/cmd/vip-next/commands/sync_confirm_test.go b/cmd/vip-next/commands/sync_confirm_test.go new file mode 100644 index 000000000..01508ea38 --- /dev/null +++ b/cmd/vip-next/commands/sync_confirm_test.go @@ -0,0 +1,202 @@ +package commands + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync/atomic" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +var testANSIRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// stripTestANSI removes SGR escapes. Used only where internal/output's table +// renderer emits colour unconditionally; NO_COLOR already suppresses colour +// everywhere fatih/color is used. +func stripTestANSI(s string) string { return testANSIRe.ReplaceAllString(s, "") } + +// syncConfirmStub answers SyncPreview with a canned body and counts how many +// times the destructive SyncEnvironment mutation was issued. +type syncConfirmStub struct { + previewBody string + previewHits atomic.Int32 + mutationHits atomic.Int32 +} + +func (s *syncConfirmStub) start() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"ResolveAppByID"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"my-app","type":"WordPress","typeId":2, + "environments":[{"id":7,"appId":7,"name":"develop","type":"develop", + "uniqueLabel":"develop","defaultDomain":"example.go-vip.net","isMultisite":false}]}}}`)) + case strings.Contains(bs, `"operationName":"SyncPreview"`): + s.previewHits.Add(1) + _, _ = w.Write([]byte(s.previewBody)) + case strings.Contains(bs, `"operationName":"SyncEnvironment"`): + s.mutationHits.Add(1) + _, _ = w.Write([]byte(`{"data":{"syncEnvironment":{"environment":{"id":7}}}}`)) + case strings.Contains(bs, `"operationName":"SyncProgress"`): + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[ + {"name":"Backup","status":"success","step":"backup"}]}}]}}}`)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + })) +} + +func syncConfirmCmd(t *testing.T, srv *httptest.Server) (*cobra.Command, *bytes.Buffer) { + t.Helper() + t.Setenv("NO_COLOR", "1") + client := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + SetConfig(Config{GQLClient: client, AppCtxConfig: appctx.AppContextConfig{Client: client}}) + t.Cleanup(func() { SetConfig(Config{}) }) + + // Drive the REAL middleware chain (app resolve -> child env -> confirm -> + // handler) so the test proves the mutation is or isn't reached, not just + // what a helper returns. + cmd := SyncCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + _ = cmd.Flags().Set("app", "42") + cmd.SetContext(context.Background()) + return cmd, &stdout +} + +// Node checks syncPreview.canSync BEFORE issuing the mutation and exits 1 +// with the server's own reason (src/lib/cli/command.js:914-920). vip-next +// never queried syncPreview at all, so it fired a sync the server would +// have refused. +func TestSyncConfirmRefusesWhenCanSyncFalse(t *testing.T) { + stub := &syncConfirmStub{previewBody: `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncPreview":{"canSync":false, + "errors":[{"message":"The destination environment has a pending deploy."}, + {"message":"second error is ignored"}], + "backup":null,"replacements":[]}} + ]}}}`} + srv := stub.start() + defer srv.Close() + cmd, _ := syncConfirmCmd(t, srv) + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected an error when canSync is false") + } + want := "Could not sync to this environment: The destination environment has a pending deploy." + if err.Error() != want { + t.Errorf("err = %q, want %q", err.Error(), want) + } + if got := stub.mutationHits.Load(); got != 0 { + t.Errorf("SyncEnvironment mutation fired %d times; must be 0 when canSync is false", got) + } +} + +// canSync true: the info table carries App, Environment, the backup date +// (Node key is "From backup", value is Date#toUTCString) and the +// syncPreview.replacements table. +func TestSyncConfirmRendersBackupAndReplacements(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + stub := &syncConfirmStub{previewBody: `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncPreview":{"canSync":true,"errors":[], + "backup":{"createdAt":"2025-07-21T10:11:12.000Z"}, + "replacements":[{"from":"a.com","to":"b.com"}]}} + ]}}}`} + srv := stub.start() + defer srv.Close() + cmd, stdout := syncConfirmCmd(t, srv) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "+ From backup: Mon, 21 Jul 2025 10:11:12 GMT\n" + + "+ Replacements: \n" + + "┌───────┬───────┐\n" + + "│ from │ to │\n" + + "├───────┼───────┤\n" + + "│ a.com │ b.com │\n" + + "└───────┴───────┘\n" + + "===================================\n" + + "Command cancelled\n" + // ANSI is stripped only for the embedded table: internal/output/table.go + // emits colour unconditionally (a separate, pre-existing divergence from + // Node, which gates on TERM). Every other byte is asserted verbatim. + if stripTestANSI(stdout.String()) != want { + t.Errorf("confirm payload mismatch\n got: %q\nwant: %q", stripTestANSI(stdout.String()), want) + } + if got := stub.mutationHits.Load(); got != 0 { + t.Errorf("SyncEnvironment fired %d times after a cancel; must be 0", got) + } +} + +// No backup on the preview -> Node omits the row entirely (command.js:929). +func TestSyncConfirmOmitsBackupRowWhenAbsent(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + stub := &syncConfirmStub{previewBody: `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncPreview":{"canSync":true,"errors":[],"backup":null,"replacements":[]}} + ]}}}`} + srv := stub.start() + defer srv.Close() + cmd, stdout := syncConfirmCmd(t, srv) + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if strings.Contains(stdout.String(), "From backup") { + t.Errorf("no backup -> no 'From backup' row; got %q", stdout.String()) + } + // formatData([], 'table') is '' in Node, so the Replacements row is the + // label followed by an empty line. + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "+ Replacements: \n" + + "\n" + + "===================================\n" + + "Command cancelled\n" + if stdout.String() != want { + t.Errorf("confirm payload mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// Node's canSync guard lives INSIDE `if (requireConfirm && ! options.force)`, +// so --skip-confirmation / --force skips the syncPreview query entirely and +// goes straight to the mutation. Bug-for-bug: do not add a query Node +// wouldn't issue, and do not block a sync Node would allow. +func TestSyncSkipConfirmationSkipsSyncPreview(t *testing.T) { + t.Setenv("VIP_SYNC_INTERVAL_MS", "1") + stub := &syncConfirmStub{previewBody: `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncPreview":{"canSync":false,"errors":[{"message":"nope"}], + "backup":null,"replacements":[]}} + ]}}}`} + srv := stub.start() + defer srv.Close() + cmd, _ := syncConfirmCmd(t, srv) + _ = cmd.Flags().Set("skip-confirmation", "true") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if got := stub.previewHits.Load(); got != 0 { + t.Errorf("SyncPreview queried %d times under --skip-confirmation; Node queries 0", got) + } + if got := stub.mutationHits.Load(); got != 1 { + t.Errorf("SyncEnvironment fired %d times; want 1", got) + } +} diff --git a/cmd/vip-next/commands/sync_render.go b/cmd/vip-next/commands/sync_render.go new file mode 100644 index 000000000..ea30f84f5 --- /dev/null +++ b/cmd/vip-next/commands/sync_render.go @@ -0,0 +1,207 @@ +package commands + +import ( + "fmt" + "io" + stdsync "sync" + "time" + + "github.com/fatih/color" + + syncpkg "github.com/Automattic/vip/internal/sync" + "github.com/Automattic/vip/internal/tui" +) + +// brailleSpinner is the Node-parity spinner sequence (matches +// @wwa/single-line-log's default frame set used by vip-cli's +// upstream sync.js). +var brailleSpinner = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +// spinnerInterval is how often the spinner advances when rendering on +// TTY. Matches Node's src/bin/vip-sync.js setInterval(..., 100) exactly. +const spinnerInterval = 100 * time.Millisecond + +// markFor returns the colored leading mark for a step based on its +// status. Spinner frames are only consulted for running steps. +// +// Node parity (per src/bin/vip-sync.js): +// +// pending → dim ○ +// running → blue braille spinner frame +// success → green ✓ +// failed → red ✕ +// other → yellow ✕ (defensive: covers any new server-side status +// string we haven't enumerated yet) +func markFor(status, spinnerFrame string) string { + switch status { + case syncpkg.StatusPending: + return color.New(color.Faint).Sprint("○") + case syncpkg.StatusRunning: + return color.New(color.FgHiBlue).Sprint(spinnerFrame) + case syncpkg.StatusSuccess: + return color.New(color.FgGreen).Sprint("✓") + case syncpkg.StatusFailed: + return color.New(color.FgRed).Sprint("✕") + default: + return color.New(color.FgYellow).Sprint("✕") + } +} + +// buildSyncFrame composes the per-tick frame: blank top line, one +// " <mark> <name>" row per step (dim when pending), blank bottom line. +// The footer is rendered separately (or once the loop exits) so we keep +// the renderer's line count stable across frames. +func buildSyncFrame(steps []syncpkg.Step, spinnerFrame string) []string { + lines := make([]string, 0, len(steps)+2) + lines = append(lines, "") + for _, s := range steps { + mark := markFor(s.Status, spinnerFrame) + row := fmt.Sprintf(" %s %s", mark, s.Name) + if s.Status == syncpkg.StatusPending { + row = color.New(color.Faint).Sprint(row) + } + lines = append(lines, row) + } + lines = append(lines, "") + return lines +} + +// syncRenderer is the unified rendering surface used by runSync. TTY +// callers get an animated frame-based renderer; non-TTY callers get a +// per-transition line printer (preserving the M6 parity behavior). +type syncRenderer interface { + // OnTransition is invoked from Poll whenever a step's status + // changes. Implementations either re-render the live frame (TTY) + // or emit one stdout line (non-TTY). + OnTransition(s syncpkg.Step) + // Stop cleans up background animation goroutines (TTY) and resets + // renderer state. Safe to call multiple times. + Stop() +} + +// nonTTYRenderer preserves the pre-task-6 behavior: one stdout line +// per step transition. Used in CI, parity scenarios, and any other +// non-TTY context. +type nonTTYRenderer struct { + w io.Writer +} + +func newNonTTYRenderer(w io.Writer) *nonTTYRenderer { + return &nonTTYRenderer{w: w} +} + +func (r *nonTTYRenderer) OnTransition(s syncpkg.Step) { + fmt.Fprintln(r.w, formatStepLine(s)) +} + +func (r *nonTTYRenderer) Stop() {} + +// ttyRenderer drives the in-place frame renderer + a background ticker +// that animates the spinner between transitions. The shared state is +// the latest steps slice; OnTransition merges new step status in, +// and the ticker reads under mutex to redraw. +type ttyRenderer struct { + mu stdsync.Mutex + steps []syncpkg.Step + stepIndex map[string]int + renderer *tui.MultiLineRenderer + spinnerIdx int + done chan struct{} + loopDone stdsync.WaitGroup + stopped bool +} + +func newTTYRenderer(w io.Writer) *ttyRenderer { + r := &ttyRenderer{ + stepIndex: make(map[string]int), + renderer: tui.NewMultiLineRenderer(w, true), + done: make(chan struct{}), + } + r.loopDone.Add(1) + go r.loop() + return r +} + +// keyOf mirrors internal/sync.Poll's keying: stable step id when +// present, falling back to Name. +func keyOfStep(s syncpkg.Step) string { + if s.Step != "" { + return s.Step + } + return s.Name +} + +func (r *ttyRenderer) OnTransition(s syncpkg.Step) { + r.mu.Lock() + defer r.mu.Unlock() + k := keyOfStep(s) + if i, ok := r.stepIndex[k]; ok { + r.steps[i] = s + } else { + r.stepIndex[k] = len(r.steps) + r.steps = append(r.steps, s) + } + // Re-render immediately on transition so the user sees status + // changes without waiting for the next spinner tick. + r.renderLocked() +} + +// loop animates the spinner. Runs until Stop closes r.done. +func (r *ttyRenderer) loop() { + defer r.loopDone.Done() + tk := time.NewTicker(spinnerInterval) + defer tk.Stop() + for { + select { + case <-r.done: + return + case <-tk.C: + r.mu.Lock() + r.spinnerIdx = (r.spinnerIdx + 1) % len(brailleSpinner) + // Only redraw when there's an active spinner to advance — + // i.e. at least one running step. Otherwise we'd be doing + // pointless writes while the loop waits for the next poll. + if r.hasRunningLocked() { + r.renderLocked() + } + r.mu.Unlock() + } + } +} + +func (r *ttyRenderer) hasRunningLocked() bool { + for _, s := range r.steps { + if s.Status == syncpkg.StatusRunning { + return true + } + } + return false +} + +func (r *ttyRenderer) renderLocked() { + frame := buildSyncFrame(r.steps, brailleSpinner[r.spinnerIdx]) + r.renderer.Render(frame) +} + +func (r *ttyRenderer) Stop() { + r.mu.Lock() + if r.stopped { + r.mu.Unlock() + return + } + r.stopped = true + r.mu.Unlock() + close(r.done) + // Block until the ticker goroutine has actually exited before we + // touch r.renderer.Done(). Otherwise the ticker could be mid-render + // (mutating MultiLineRenderer.lastRows) while we reset it from the + // caller's goroutine — a data race MultiLineRenderer's "not safe for + // concurrent use" contract would surface. + r.loopDone.Wait() + // Reset so any subsequent writes from the handler (final terminal + // status line) flow naturally below the frame instead of trying to + // overwrite it. + r.renderer.Done() +} diff --git a/cmd/vip-next/commands/sync_render_test.go b/cmd/vip-next/commands/sync_render_test.go new file mode 100644 index 000000000..890b57507 --- /dev/null +++ b/cmd/vip-next/commands/sync_render_test.go @@ -0,0 +1,101 @@ +package commands + +import ( + "bytes" + "strings" + "sync" + "testing" + "time" + + syncpkg "github.com/Automattic/vip/internal/sync" +) + +// TestTTYRendererStopRaceFree exercises the Stop/goroutine-exit interlock: +// flood OnTransition concurrently with the spinner ticker, then Stop(). +// Under `go test -race` this would have failed before the loopDone +// WaitGroup was added because Stop's Done() and the ticker's Render() both +// mutate MultiLineRenderer.lastLines. +// +// The test asserts no panic + the renderer produced SOME output. Detailed +// frame-shape assertions live in internal/tui/progress_test.go. +func TestTTYRendererStopRaceFree(t *testing.T) { + // A concurrent-safe buffer so multiple goroutines can write without + // tripping bytes.Buffer's "not safe for concurrent use" implicit contract. + buf := &syncBuf{} + r := newTTYRenderer(buf) + + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < 50; j++ { + r.OnTransition(syncpkg.Step{ + Step: "step-" + string(rune('a'+id)), + Name: "step", + Status: syncpkg.StatusRunning, + }) + } + }(i) + } + // Let the ticker fire a few times alongside the OnTransition flood. + time.Sleep(spinnerInterval * 3) + + r.Stop() + wg.Wait() + + // Idempotent stop: must not panic on a second call. + r.Stop() + + if buf.len() == 0 { + t.Error("expected some output written; got none") + } + // Spinner / step text should appear somewhere in the buffer. + if !strings.Contains(buf.String(), "step") { + t.Errorf("step name missing from rendered output") + } +} + +// TestTTYRendererStopWithoutTransitions covers the no-op path: Stop() +// before any OnTransition fired. Must not block (loopDone.Wait must +// return promptly) and must not panic. +func TestTTYRendererStopWithoutTransitions(t *testing.T) { + buf := &syncBuf{} + r := newTTYRenderer(buf) + done := make(chan struct{}) + go func() { + r.Stop() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Stop did not return within 1s (goroutine leak suspected)") + } +} + +// syncBuf is a tiny concurrent-safe writer for tests that hammer a +// renderer from multiple goroutines. bytes.Buffer alone would trip +// `go test -race` even without the bug under test. +type syncBuf struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuf) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuf) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func (b *syncBuf) len() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Len() +} diff --git a/cmd/vip-next/commands/sync_test.go b/cmd/vip-next/commands/sync_test.go new file mode 100644 index 000000000..6902f9f84 --- /dev/null +++ b/cmd/vip-next/commands/sync_test.go @@ -0,0 +1,148 @@ +package commands + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" +) + +// syncSeqStub responds with different bodies per operationName and per +// hit count. SyncProgress hits cycle through `progresses`; SyncEnvironment +// returns `syncStartBody` (or default). +type syncSeqStub struct { + mu sync.Mutex + startHits atomic.Int32 + progressHits atomic.Int32 + syncStartBody string + progressBodies []string +} + +func (s *syncSeqStub) start(_ *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.Contains(bs, `"operationName":"SyncEnvironment"`): + s.startHits.Add(1) + if s.syncStartBody == "" { + _, _ = w.Write([]byte(`{"data":{"syncEnvironment":{"environment":{"id":7}}}}`)) + return + } + _, _ = w.Write([]byte(s.syncStartBody)) + case strings.Contains(bs, `"operationName":"SyncProgress"`): + i := int(s.progressHits.Add(1) - 1) + s.mu.Lock() + defer s.mu.Unlock() + if i >= len(s.progressBodies) { + i = len(s.progressBodies) - 1 + } + _, _ = w.Write([]byte(s.progressBodies[i])) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + })) +} + +// TestSyncHappyPath drives runSync through the happy flow: mutation +// succeeds, then a "running" -> "success" status sequence. Verifies +// the banner + the terminal success line. +func TestSyncHappyPath(t *testing.T) { + stub := &syncSeqStub{ + progressBodies: []string{ + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":1,"steps":[ + {"name":"Backup","status":"running","step":"backup"} + ]}} + ]}}}`, + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[ + {"name":"Backup","status":"success","step":"backup"} + ]}} + ]}}}`, + }, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + // Tight poll interval so test finishes quickly. + t.Setenv("VIP_SYNC_INTERVAL_MS", "1") + t.Setenv("NO_COLOR", "1") // strip color escapes for stable substring asserts + + cmd := SyncCmd() + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runSync(cmd, nil); err != nil { + t.Fatalf("runSync: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "syncing:") { + t.Errorf("stdout missing banner; got %q", out) + } + if !strings.Contains(out, "Data Sync is finished") { + t.Errorf("stdout missing terminal line; got %q", out) + } + if stub.startHits.Load() != 1 { + t.Errorf("SyncEnvironment hits = %d, want 1", stub.startHits.Load()) + } + if stub.progressHits.Load() < 2 { + t.Errorf("SyncProgress hits = %d, want >= 2", stub.progressHits.Load()) + } +} + +// TestSyncAlreadySyncing drives the path where SyncEnvironment returns +// the "Site is already syncing" GraphQL error: runSync should print the +// yellow Note and proceed to polling. +func TestSyncAlreadySyncing(t *testing.T) { + stub := &syncSeqStub{ + syncStartBody: `{"data":null,"errors":[{"message":"Site is already syncing"}]}`, + progressBodies: []string{ + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[]}} + ]}}}`, + }, + } + srv := stub.start(t) + defer srv.Close() + setupEnvvarConfig(srv) + defer SetConfig(Config{}) + + t.Setenv("VIP_SYNC_INTERVAL_MS", "1") + t.Setenv("NO_COLOR", "1") + + cmd := SyncCmd() + _ = cmd.Flags().Set("skip-confirmation", "true") + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetContext(ctxWithAppEnv(42, 7)) + + if err := runSync(cmd, nil); err != nil { + t.Fatalf("runSync: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "A data sync is already running") { + t.Errorf("stdout must include already-running Note; got %q", out) + } + if !strings.Contains(out, "Data Sync is finished") { + t.Errorf("stdout missing terminal line; got %q", out) + } + if stub.startHits.Load() != 1 { + t.Errorf("SyncEnvironment hits = %d, want 1", stub.startHits.Load()) + } + if stub.progressHits.Load() < 1 { + t.Errorf("SyncProgress hits = %d, want >= 1", stub.progressHits.Load()) + } +} diff --git a/cmd/vip-next/commands/whoami.go b/cmd/vip-next/commands/whoami.go new file mode 100644 index 000000000..761179712 --- /dev/null +++ b/cmd/vip-next/commands/whoami.go @@ -0,0 +1,152 @@ +package commands + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + + json "encoding/json/v2" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/telemetry" +) + +// Config holds runtime values that main.go injects before cobra dispatches. +type Config struct { + APIHost string + Token string + // Middleware is the ordered (outermost first) gql.Middleware stack that + // handlers should attach to gql.Client instances. Wired by main.go. + // Per the M3 contract: error → rechallenge → retry → transport. + Middleware []gql.Middleware + + // M4 additions: + // GQLClient is the genqlient client wrapped around the same middleware + // chain (via gql.HTTPClientWithMiddleware). Handlers and appctx + // middleware share this single client. + GQLClient graphql.Client + // Tracker emits telemetry events. Never nil in production — main.go + // constructs telemetry.NewDefault(), which returns a disabled tracker + // rather than nil when DO_NOT_TRACK / test env signals opt-out. + Tracker *telemetry.Tracker + // AppCtxConfig is consumed by appctx.WithAppContext to resolve --app + // against the GraphQL API. + AppCtxConfig appctx.AppContextConfig +} + +// pkgConfig is process-wide mutable state. DO NOT call t.Parallel() in any +// test that calls SetConfig — the AppCtxConfig.Client and Tracker fields are +// per-invocation in production but per-test in tests, and parallel tests +// would race over them. If a future test needs parallelism, refactor so the +// Config flows through cmd.Context() instead of this package var. +var pkgConfig Config + +// SetConfig stores runtime config (called by main.go after token validation, +// or by tests). See the pkgConfig comment re: t.Parallel(). +func SetConfig(c Config) { pkgConfig = c } + +// GetConfig returns the currently stored runtime config. +func GetConfig() Config { return pkgConfig } + +const meQuery = `{"operationName":"Me","query":"query Me {\n me {\n id\n displayName\n isVIP\n }\n}"}` + +type WhoamiDeps struct { + APIHost string + Token string + Client *gql.Client + Stdout io.Writer +} + +func RunWhoami(deps WhoamiDeps) error { + if deps.Stdout == nil { + deps.Stdout = os.Stdout + } + if deps.Client == nil { + deps.Client = gql.NewClient(gql.Config{ + APIHost: deps.APIHost, + Token: deps.Token, + Middleware: pkgConfig.Middleware, + }) + } + req, err := http.NewRequest("POST", deps.APIHost+"/graphql", strings.NewReader(meQuery)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := deps.Client.Do(req) + if err != nil { + return fmt.Errorf("Failed to fetch information about the currently logged-in user error: %s", err.Error()) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var doc struct { + Data struct { + Me *struct { + ID int64 `json:"id"` + DisplayName string `json:"displayName"` + IsVIP bool `json:"isVIP"` + } `json:"me"` + } `json:"data"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return fmt.Errorf("Failed to fetch information about the currently logged-in user error: %s", err.Error()) + } + if doc.Data.Me == nil { + return errors.New("The API did not return any information about the user.") + } + + displayName := doc.Data.Me.DisplayName + if displayName == "" { + displayName = "user" + } + var id string + if doc.Data.Me.ID != 0 { + id = fmt.Sprintf("%d", doc.Data.Me.ID) + } else { + id = " not found" + } + + fmt.Fprintf(deps.Stdout, "- Howdy %s!\n", displayName) + fmt.Fprintf(deps.Stdout, "- Your user ID is %s\n", id) + if doc.Data.Me.IsVIP { + fmt.Fprintln(deps.Stdout, "- Your account has VIP Staff permissions") + } + return nil +} + +// NewWhoamiCmd returns a cobra.Command that wraps RunWhoami. +func NewWhoamiCmd() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Retrieve details about the current authenticated VIP-CLI user.", + Long: "Retrieve details about the current authenticated VIP-CLI user.", + RunE: func(cmd *cobra.Command, args []string) error { + host := pkgConfig.APIHost + if host == "" { + host = defaultAPIHost() + } + return RunWhoami(WhoamiDeps{ + APIHost: host, + Token: pkgConfig.Token, + }) + }, + } +} + +func defaultAPIHost() string { + if h := os.Getenv("API_HOST"); h != "" { + return h + } + return "https://api.wpvip.com" +} diff --git a/cmd/vip-next/commands/whoami_test.go b/cmd/vip-next/commands/whoami_test.go new file mode 100644 index 000000000..f7005a3e0 --- /dev/null +++ b/cmd/vip-next/commands/whoami_test.go @@ -0,0 +1,85 @@ +package commands + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWhoamiSendsBearerToken(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + w.Write([]byte(`{"data":{"me":{"id":1,"displayName":"x"}}}`)) + })) + defer srv.Close() + var stdout bytes.Buffer + if err := RunWhoami(WhoamiDeps{APIHost: srv.URL, Token: "test-jwt-token", Stdout: &stdout}); err != nil { + t.Fatalf("RunWhoami: %v", err) + } + if got != "Bearer test-jwt-token" { + t.Errorf("Authorization = %q, want Bearer test-jwt-token", got) + } +} + +func TestWhoamiSuccessOutput(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"me":{"id":42,"displayName":"Test User","trackingUserId":"42","isVIP":true}}}`)) + })) + defer srv.Close() + var stdout bytes.Buffer + err := RunWhoami(WhoamiDeps{APIHost: srv.URL, Stdout: &stdout}) + if err != nil { + t.Fatalf("RunWhoami: %v", err) + } + got := stdout.String() + want := "- Howdy Test User!\n- Your user ID is 42\n- Your account has VIP Staff permissions\n" + if got != want { + t.Errorf("output mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestWhoamiNoDisplayName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":{"me":{"id":7}}}`)) + })) + defer srv.Close() + var stdout bytes.Buffer + err := RunWhoami(WhoamiDeps{APIHost: srv.URL, Stdout: &stdout}) + if err != nil { + t.Fatalf("RunWhoami: %v", err) + } + if !strings.Contains(stdout.String(), "- Howdy user!") { + t.Errorf("missing default displayName: %q", stdout.String()) + } +} + +func TestWhoamiNoIDPrintsNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":{"me":{"displayName":"x"}}}`)) + })) + defer srv.Close() + var stdout bytes.Buffer + RunWhoami(WhoamiDeps{APIHost: srv.URL, Stdout: &stdout}) + if !strings.Contains(stdout.String(), "- Your user ID is not found") { + t.Errorf("missing not-found marker (note leading space): %q", stdout.String()) + } +} + +func TestWhoamiNullMeReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + var stdout bytes.Buffer + err := RunWhoami(WhoamiDeps{APIHost: srv.URL, Stdout: &stdout}) + if err == nil { + t.Fatal("expected an error when me is null") + } + if !strings.Contains(err.Error(), "The API did not return any information about the user.") { + t.Errorf("error message mismatch: %v", err) + } +} diff --git a/cmd/vip-next/commands/wp.go b/cmd/vip-next/commands/wp.go new file mode 100644 index 000000000..15fc7715b --- /dev/null +++ b/cmd/vip-next/commands/wp.go @@ -0,0 +1,374 @@ +package commands + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/Khan/genqlient/graphql" + "github.com/fatih/color" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/exit" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/output" + "github.com/Automattic/vip/internal/version" + "github.com/Automattic/vip/internal/wpshell" + "github.com/Automattic/vip/internal/wpssh" + "github.com/Automattic/vip/internal/wpstream" +) + +// wpYes is set by main.go's normalizeWPArgs extraction before Execute +// (DisableFlagParsing keeps cobra from parsing the --yes flag itself). +var wpYes bool + +// SetWPYes records the extracted --yes flag. Called by main.go. +func SetWPYes(v bool) { wpYes = v } + +// nodejsTypeIDs — NODEJS_SITE_TYPE_IDS (src/lib/constants/vipgo.ts:12). +var nodejsTypeIDs = map[int64]bool{3: true, 5: true, 7: true, 8: true} + +// wpEnvInfo flattens WPEnvInfo: the per-env fields the wp command needs +// beyond what appctx resolves (Node appQuery — vip-wp.js:26). +type wpEnvInfo struct { + AppTypeID int64 + WpcliStrategy string + PrimaryDomainName string +} + +func fetchWPEnvInfo(ctx context.Context, client graphql.Client, appID, envID int64) (*wpEnvInfo, error) { + resp, err := gql.WPEnvInfo(ctx, client, appID, envID) + if err != nil { + return nil, err + } + info := &wpEnvInfo{} + if resp == nil || resp.App == nil { + return info, nil + } + if resp.App.TypeId != nil { + info.AppTypeID = *resp.App.TypeId + } + if len(resp.App.Environments) > 0 && resp.App.Environments[0] != nil { + env := resp.App.Environments[0] + if env.WpcliStrategy != nil { + info.WpcliStrategy = string(*env.WpcliStrategy) + } + if env.PrimaryDomain != nil { + info.PrimaryDomainName = env.PrimaryDomain.Name + } + } + return info, nil +} + +// WPCmd returns `vip wp [args...]`. +// +// Node parity: src/bin/vip-wp.js. DisableFlagParsing so WP-CLI flags pass +// through; main.go's normalizeWPArgs handles the `--`/`--yes` reshaping. +func WPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "wp", + Short: "Run a WP-CLI command on an environment", + Long: "Run a WP-CLI command on a VIP Platform environment, or launch an interactive WP-CLI shell when no command is given.", + DisableFlagParsing: true, + Args: cobra.ArbitraryArgs, + } + cfg := GetConfig() + return appctx.Build(cmd, + appctx.WithAppContext(cfg.AppCtxConfig), + appctx.WithEnvContext(), + ).WithRun(runWP) +} + +func runWP(cmd *cobra.Command, args []string) error { + ae := appctx.FromContext(cmd.Context()) + if ae == nil { + return errors.New("appctx not set; this is a wiring bug") + } + cfg := GetConfig() + out := cmd.OutOrStdout() + + info, err := fetchWPEnvInfo(gql.WithAllowGQLErrors(cmd.Context()), cfg.GQLClient, ae.App.ID, ae.Env.ID) + if err != nil { + return err + } + + if nodejsTypeIDs[info.AppTypeID] { + return errors.New("WP-CLI commands are not supported on Node.js environments.") + } + + isSubShell := len(args) == 0 + + if !isSubShell && ae.Env.Type == "production" && !wpYes { + // Node parity (vip-wp.js:379-391): the production gate is a + // confirm() with a one-row info table echoing the WP-CLI command + // that is about to run, so the user approves the exact string that + // will be dispatched. The value is built the same way the dispatch + // layer builds it — requoteArgs(args) joined by spaces — so the two + // can never drift. + fmt.Fprintln(out, output.KeyValue([]output.Tuple{ + {Key: "command", Value: "wp " + strings.Join(wpshell.RequoteArgs(args), " ")}, + })) + ok, perr := importConfirmPrompt(cmd, + fmt.Sprintf("Are you sure you want to run this command on %s for site %s?", + formatEnvironment(ae.Env.Type), ae.App.Name), false) + if perr != nil || !ok { + trackEvent("wpcli_confirm_cancel", nil) + fmt.Fprintln(out, "Command cancelled") + return nil + } + } + + return dispatchWP(cmd, ae, info, args, isSubShell) +} + +// dispatchWP routes to the appropriate WP-CLI execution strategy based on +// info.WpcliStrategy. Ports vip-wp.js + wp-ssh.ts dispatch logic. +func dispatchWP(cmd *cobra.Command, ae *appctx.AppEnv, info *wpEnvInfo, args []string, isSubShell bool) error { + cfg := GetConfig() + out := cmd.OutOrStdout() + + // 1. Websocket strategy: socket.io (WP2). + if info.WpcliStrategy == "websocket" { + return dispatchWPWebsocket(cmd, ae, info, args, isSubShell) + } + + // 2. SSH strategy (all non-websocket strategies). + // + // Node-parity quirk: SSH envs run the joined command string even in + // "subshell" mode (no args). wp-ssh.ts never enters a REPL — it always + // calls executeCommandOverSSH with whatever cmd string it has, including + // empty. So we do NOT use wpshell.REPL here; we build the string and run + // once regardless. + cmdStr := strings.Join(wpshell.RequoteArgs(args), " ") + + method := "shell" + if isSubShell { + method = "subshell" + } + trackEvent("wpcli_command_execute", map[string]any{"method": method}) + + // Call TriggerWPCLICommand under WithAllowGQLErrors so GraphQL errors + // come back to us instead of calling os.Exit via the middleware. + triggerInput := &gql.AppEnvironmentTriggerWPCLICommandInput{ + Command: &cmdStr, + Id: &ae.App.ID, + EnvironmentId: &ae.Env.ID, + } + triggerCtx := gql.WithAllowGQLErrors(cmd.Context()) + resp, err := gql.TriggerWPCLICommand(triggerCtx, cfg.GQLClient, triggerInput) + if err != nil { + // Surface GraphQL error in the same format other commands use for + // allowed-error contexts: print "Error: <msg>" in red and return an + // error so the caller sees a non-zero exit (sync.go:80 pattern). + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return err + } + + payload := resp.GetTriggerWPCLICommandOnAppEnvironment() + if payload == nil { + err := errors.New("WP-CLI SSH Authentication failed") + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return err + } + + sshAuth := payload.GetSshAuthentication() + if sshAuth == nil { + // wp-ssh.ts:114 + err := errors.New("WP-CLI SSH Authentication failed") + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return err + } + + // Extract GUID and InputToken from the payload. + var guid string + if c := payload.GetCommand(); c != nil && c.GetGuid() != nil { + guid = *c.GetGuid() + } + var inputToken string + if t := payload.GetInputToken(); t != nil { + inputToken = *t + } + + auth := wpssh.Auth{ + Host: sshAuth.GetHost(), + Port: sshAuth.GetPort(), + Username: sshAuth.GetUsername(), + PrivateKey: sshAuth.GetPrivateKey(), + Passphrase: sshAuth.GetPassphrase(), + GUID: guid, + InputToken: inputToken, + } + + // Determine terminal dimensions (sync.go pattern for term.IsTerminal / + // term.GetSize). NON_TTY_ROWS/COLUMNS from wp-ssh.ts (15 / 100). + tty := term.IsTerminal(int(os.Stdout.Fd())) + rows, cols := 15, 100 + if tty { + if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil { + cols = w + rows = h + } + } + + // Signal handling: io.Pipe approach so SIGINT/SIGTERM cancel bytes can + // be injected into the same stdin stream alongside real user input. + // Node wp-ssh.ts:214-226: SIGINT → "\x03", SIGTERM → "\x1F". + pr, pw := io.Pipe() + // Copy real stdin into the write-end of the pipe in a goroutine. + go func() { + _, _ = io.Copy(pw, os.Stdin) + _ = pw.Close() + }() + // Close the write-end on return so the SSH session's stdin reader sees + // EOF and tears down cleanly. NOTE: the copy goroutine above blocks in + // os.Stdin.Read, which Go cannot force-cancel; closing pw here unblocks + // the consumer (pr) but the goroutine itself is only reaped on process + // exit. Harmless for a one-shot CLI — Node's wp-ssh has the same + // stdin-pipe limitation. + defer func() { _ = pw.Close() }() + + sigCh := make(chan os.Signal, 2) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + for sig := range sigCh { + // Node wp-ssh.ts:216-224 uses stream.end(byte): it writes the + // cancel byte AND half-closes remote stdin so the server sees + // EOF. Mirror that with a write followed by pw.Close() (the + // deferred Close and the stdin-copy goroutine's Close are both + // idempotent on an io.PipeWriter, so this is safe). + switch sig { + case syscall.SIGINT: + fmt.Fprintln(os.Stderr, "SIGINT received. Canceling command...") + _, _ = pw.Write([]byte("\x03")) + _ = pw.Close() + case syscall.SIGTERM: + fmt.Fprintln(os.Stderr, "SIGTERM received. Canceling command...") + _, _ = pw.Write([]byte("\x1F")) + _ = pw.Close() + } + } + }() + defer func() { + signal.Stop(sigCh) + close(sigCh) + }() + + meta := wpssh.Meta{ + Version: version.Version, + Rows: rows, + Columns: cols, + TTY: tty, + } + streams := wpssh.Streams{ + Stdin: pr, + Stdout: os.Stdout, + Stderr: os.Stderr, + } + + runErr := wpssh.Run(cmd.Context(), auth, streams, meta) + + var ec *wpssh.ExitCodeError + if errors.As(runErr, &ec) { + trackEvent("wpcli_command_end", map[string]any{"method": method}) + exit.WithCode(ec.Code, nil) + return nil // unreachable — exit.WithCode calls os.Exit + } + if runErr != nil { + return runErr + } + + trackEvent("wpcli_command_end", map[string]any{"method": method}) + return nil +} + +// dispatchWPWebsocket handles the "websocket" wpcliStrategy by connecting to +// the environment over socket.io (internal/wpstream, WP2). +// +// NOTE: Node's socket.io path supports an interactive REPL for subshell mode, +// but WP2 ships single-command socket.io first. The isSubShell parameter is +// accepted for signature symmetry; the websocket branch runs the (possibly +// empty) joined command string regardless — same as the SSH branch's documented +// quirk. The interactive REPL over socket.io (internal/wpshell.REPL) is a +// follow-up task. +func dispatchWPWebsocket(cmd *cobra.Command, ae *appctx.AppEnv, _ *wpEnvInfo, args []string, isSubShell bool) error { + cfg := GetConfig() + out := cmd.OutOrStdout() + + // Build the WP-CLI command string (single-command mode; REPL is a follow-up). + cmdStr := strings.Join(wpshell.RequoteArgs(args), " ") + + method := "shell" + if isSubShell { + method = "subshell" + } + trackEvent("wpcli_command_execute", map[string]any{"method": method}) + + // Call TriggerWPCLICommand under WithAllowGQLErrors so GraphQL errors come + // back to us rather than triggering an os.Exit in the middleware. + triggerInput := &gql.AppEnvironmentTriggerWPCLICommandInput{ + Command: &cmdStr, + Id: &ae.App.ID, + EnvironmentId: &ae.Env.ID, + } + triggerCtx := gql.WithAllowGQLErrors(cmd.Context()) + resp, err := gql.TriggerWPCLICommand(triggerCtx, cfg.GQLClient, triggerInput) + if err != nil { + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return err + } + + payload := resp.GetTriggerWPCLICommandOnAppEnvironment() + if payload == nil { + err := errors.New("WP-CLI command trigger failed: empty payload") + fmt.Fprintln(out, color.RedString("Error: "+err.Error())) + return err + } + + // Extract GUID and InputToken from the payload. + // NOTE: for websocket envs sshAuthentication will be null — we do NOT + // require it (unlike the SSH branch). + var guid string + if c := payload.GetCommand(); c != nil && c.GetGuid() != nil { + guid = *c.GetGuid() + } + var inputToken string + if t := payload.GetInputToken(); t != nil { + inputToken = *t + } + + // Determine terminal dimensions (same defaults as SSH branch: 15 rows / 100 cols). + tty := term.IsTerminal(int(os.Stdout.Fd())) + rows, cols := 15, 100 + if tty { + if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil { + cols = w + rows = h + } + } + + res, runErr := wpstream.Run(cmd.Context(), wpstream.Options{ + APIHost: cfg.APIHost, + Token: cfg.Token, + GUID: guid, + InputToken: inputToken, + Columns: cols, + Rows: rows, + IsTTY: tty, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + if runErr != nil { + return runErr + } + trackEvent("wpcli_command_end", map[string]any{"method": method}) + exit.WithCode(res.ExitCode, nil) + return nil // unreachable — exit.WithCode calls os.Exit +} diff --git a/cmd/vip-next/commands/wp_test.go b/cmd/vip-next/commands/wp_test.go new file mode 100644 index 000000000..4b77aaf9b --- /dev/null +++ b/cmd/vip-next/commands/wp_test.go @@ -0,0 +1,276 @@ +package commands + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// wpEnvInfoBody builds a minimal WPEnvInfo JSON response. +func wpEnvInfoBody(typeID int64) string { + return `{"data":{"app":{"id":42,"name":"parityapp","typeId":` + + strconv.FormatInt(typeID, 10) + + `,"environments":[{"id":7,"appId":42,"type":"production","name":"production","wpcliStrategy":"ssh","primaryDomain":{"name":"example.com"}}]}}}` +} + +// wpEnvInfoBodyWithStrategy builds a WPEnvInfo JSON response with a custom strategy and env type. +func wpEnvInfoBodyWithStrategy(typeID int64, strategy, envType string) string { + return `{"data":{"app":{"id":42,"name":"parityapp","typeId":` + + strconv.FormatInt(typeID, 10) + + `,"environments":[{"id":7,"appId":42,"type":"` + envType + `","name":"` + envType + `","wpcliStrategy":"` + strategy + `","primaryDomain":{"name":"example.com"}}]}}}` +} + +// wpStub serves WPEnvInfo and optionally TriggerWPCLICommand responses. +type wpStub struct { + body string + triggerBody string // if empty, returns {"data":null} + triggerHits atomic.Int32 +} + +func (s *wpStub) start(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bs := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(bs, `"operationName":"WPEnvInfo"`): + _, _ = w.Write([]byte(s.body)) + case strings.Contains(bs, `"operationName":"TriggerWPCLICommand"`): + s.triggerHits.Add(1) + tb := s.triggerBody + if tb == "" { + tb = `{"data":null}` + } + _, _ = w.Write([]byte(tb)) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func setupWPTest(t *testing.T, stub *wpStub) { + t.Helper() + srv := stub.start(t) + SetConfig(Config{GQLClient: graphql.NewClient(srv.URL+"/graphql", srv.Client()), APIHost: srv.URL, Token: "tok"}) + t.Cleanup(func() { SetConfig(Config{}) }) + t.Setenv("NO_COLOR", "1") +} + +// wpCtx builds a context with a pre-resolved AppEnv. The envType controls +// the production-confirm gate (ae.Env.Type), typeID is the app type for +// the appctx.App (not used by the Node.js gate which reads info.AppTypeID +// from WPEnvInfo, but populated for completeness). +func wpCtx(appID, envID, typeID int64, envType string) context.Context { + return appctx.WithAppEnv(context.Background(), &appctx.AppEnv{ + App: appctx.App{ID: appID, Name: "parityapp", TypeId: typeID}, + Env: appctx.Env{ID: envID, Name: envType, Type: envType}, + }) +} + +// TestWPNodejsRejected: WPEnvInfo returns typeId:3 (Node.js site) → +// runWP must return the exact Node.js rejection error. +func TestWPNodejsRejected(t *testing.T) { + stub := &wpStub{body: wpEnvInfoBody(3)} + setupWPTest(t, stub) + + cmd := WPCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 3, "develop")) + + err := runWP(cmd, []string{"site", "list"}) + want := "WP-CLI commands are not supported on Node.js environments." + if err == nil || err.Error() != want { + t.Errorf("err = %v, want %q", err, want) + } +} + +// TestWPProductionConfirmDeclined: non-Node.js app, production env, no +// --yes, confirm stub declines → returns nil + "Command cancelled" on stdout. +func TestWPProductionConfirmDeclined(t *testing.T) { + stub := &wpStub{body: wpEnvInfoBody(2)} + setupWPTest(t, stub) + defer SetWPYes(false) // ensure no state leak + SetWPYes(false) + + restore := stubImportPrompts("", false) // confirm = false = decline + defer restore() + + cmd := WPCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 2, "production")) + + err := runWP(cmd, []string{"user", "list"}) + if err != nil { + t.Errorf("err = %v, want nil", err) + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("stdout = %q, want 'Command cancelled'", stdout.String()) + } +} + +// TestWPProductionConfirmSkippedWithYes: non-Node.js app, production env, +// SetWPYes(true) skips the confirm gate entirely → runWP reaches the SSH +// dispatch and calls TriggerWPCLICommand. The stub returns nil data so the +// SSH auth check surfaces an error. +func TestWPProductionConfirmSkippedWithYes(t *testing.T) { + stub := &wpStub{ + body: wpEnvInfoBody(2), + triggerBody: `{"data":{"triggerWPCLICommandOnAppEnvironment":null}}`, + } + setupWPTest(t, stub) + SetWPYes(true) + defer SetWPYes(false) // always reset + + cmd := WPCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 2, "production")) + + err := runWP(cmd, []string{"user", "list"}) + // The dispatch now reaches SSH path; TriggerWPCLICommand returns a null + // payload, so we expect "WP-CLI SSH Authentication failed". + if err == nil || !strings.Contains(err.Error(), "WP-CLI SSH Authentication failed") { + t.Errorf("err = %v, want error containing 'WP-CLI SSH Authentication failed'", err) + } + if stub.triggerHits.Load() != 1 { + t.Errorf("TriggerWPCLICommand hits = %d, want 1", stub.triggerHits.Load()) + } +} + +// TestWPWebsocketStrategyDispatches: WPEnvInfo returns wpcliStrategy "websocket" +// → runWP must NO LONGER redirect to the Node CLI; it must reach the +// TriggerWPCLICommand mutation (WP2 socket.io path). +// +// We stub TriggerWPCLICommand to return a GraphQL error body. That proves we +// entered dispatchWPWebsocket without needing a live socket.io server. +// The key assertion: error does NOT contain "requires the Node CLI", and the +// trigger mutation DID fire. +func TestWPWebsocketStrategyDispatches(t *testing.T) { + stub := &wpStub{ + body: wpEnvInfoBodyWithStrategy(2, "websocket", "develop"), + triggerBody: `{"data":null,"errors":[{"message":"unauthorized"}]}`, + } + setupWPTest(t, stub) + SetWPYes(true) // skip production confirm; env is develop so irrelevant + defer SetWPYes(false) + + cmd := WPCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 2, "develop")) + + err := runWP(cmd, []string{"site", "list"}) + // Must return a non-nil error (the GraphQL "unauthorized" error). + if err == nil { + t.Fatal("expected non-nil error from websocket dispatch path") + } + // Must NOT be the old redirect error. + if strings.Contains(err.Error(), "requires the Node CLI") { + t.Errorf("err = %v — still redirecting to Node CLI, websocket wiring not complete", err) + } + // The GraphQL error text must surface somewhere. + combined := stdout.String() + err.Error() + if !strings.Contains(combined, "unauthorized") { + t.Errorf("expected 'unauthorized' in output+err, got stdout=%q err=%v", stdout.String(), err) + } + // TriggerWPCLICommand MUST have fired. + if stub.triggerHits.Load() != 1 { + t.Errorf("TriggerWPCLICommand hits = %d, want 1", stub.triggerHits.Load()) + } +} + +// TestWPSSHTriggerError: SSH strategy, TriggerWPCLICommand returns a GraphQL +// error → runWP must return a non-nil error and stdout must contain the error +// message. Proves the SSH path reaches the mutation and surfaces errors +// without needing a live SSH server. +func TestWPSSHTriggerError(t *testing.T) { + stub := &wpStub{ + body: wpEnvInfoBodyWithStrategy(2, "ssh", "develop"), + triggerBody: `{"data":null,"errors":[{"message":"command not allowed","locations":[],"path":null}]}`, + } + setupWPTest(t, stub) + SetWPYes(true) + defer SetWPYes(false) + + cmd := WPCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 2, "develop")) + + err := runWP(cmd, []string{"user", "list"}) + if err == nil { + t.Fatal("expected non-nil error for GraphQL trigger error") + } + // The surfaced error message must contain the GraphQL error text. + combined := stdout.String() + err.Error() + if !strings.Contains(combined, "command not allowed") { + t.Errorf("expected 'command not allowed' in output+err, got stdout=%q err=%v", stdout.String(), err) + } + if stub.triggerHits.Load() != 1 { + t.Errorf("TriggerWPCLICommand hits = %d, want 1", stub.triggerHits.Load()) + } +} + +// Node's production gate calls confirm([{key:'command', value:`wp ${cmd}`}], +// …) — it SHOWS the user the WP-CLI command that is about to run against +// production (src/bin/vip-wp.js:379-391). vip-next only asked the question. +// +// The echoed string is requoteArgs(args).join(' '), the same assembly the +// dispatch layer sends to the platform, so what the user approves is exactly +// what runs. +func TestWPProductionConfirmEchoesCommand(t *testing.T) { + stub := &wpStub{body: wpEnvInfoBody(2)} + setupWPTest(t, stub) + defer SetWPYes(false) + SetWPYes(false) + t.Setenv("NO_COLOR", "1") + + var seen string + origConfirm := importConfirmPrompt + importConfirmPrompt = func(_ *cobra.Command, message string, _ bool) (bool, error) { + seen = message + return false, nil + } + defer func() { importConfirmPrompt = origConfirm }() + + cmd := WPCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetContext(wpCtx(42, 7, 2, "production")) + + if err := runWP(cmd, []string{"post", "list", "--format=json"}); err != nil { + t.Fatalf("runWP: %v", err) + } + + want := "===================================\n" + + `+ command: wp "post" "list" "--format=json"` + "\n" + + "===================================\n" + + "Command cancelled\n" + if stdout.String() != want { + t.Errorf("wp confirm output mismatch\n got: %q\nwant: %q", stdout.String(), want) + } + wantMsg := "Are you sure you want to run this command on PRODUCTION for site parityapp?" + if seen != wantMsg { + t.Errorf("prompt message = %q, want %q", seen, wantMsg) + } +} diff --git a/cmd/vip-next/flags_node_parity_test.go b/cmd/vip-next/flags_node_parity_test.go new file mode 100644 index 000000000..90753d0e9 --- /dev/null +++ b/cmd/vip-next/flags_node_parity_test.go @@ -0,0 +1,421 @@ +package main + +import ( + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// nodeShortFlags is the per-command short-alias table the Node CLI actually +// exposes. It is not a style choice: Node's createOptionDefinition +// (src/lib/cli/command.js:62-82) derives a one-character short alias for EVERY +// option from the first letter of its long name, reserving only h/v/d and +// skipping a letter already taken on that command. The registration order is +// what resolves collisions, and it is fixed: +// +// --app (appContext||requireConfirm), --env (envContext||childEnvContext), +// --force (requireConfirm), --format (format), -h/--help, -v/--version, +// -d/--debug, then the bin's own .option() calls in source order +// (command.js:1075-1111). +// +// That is why, for example, `vip logs --format` has NO short (`-f` went to +// --follow, registered first) while `vip slowlogs --format` DOES (`format: +// true` is registered by the factory, before the bin's own options). +// +// Keys are Go command paths; values map the Go flag name to Node's short. +// Where vip-next renamed a flag, the Node source line is called out. +var nodeShortFlags = map[string]map[string]string{ + // src/bin/vip.js — only the three globals. + "vip-next": {"debug": "d"}, + + // Go-only commands. No Node bin exists; the entries are what Node's + // derivation rule would produce for an appContext+envContext command, + // so the surface stays self-consistent. + "vip-next login": {}, + "vip-next defensive-mode": {}, + "vip-next defensive-mode enable": {"app": "a", "env": "e"}, + "vip-next defensive-mode disable": {"app": "a", "env": "e"}, + "vip-next defensive-mode configure": {"app": "a", "env": "e"}, + + "vip-next logout": {}, // src/bin/vip-logout.ts + "vip-next whoami": {}, // src/bin/vip-whoami.ts + + // src/bin/vip-logs.js:241-257 — type, limit, follow, format (f taken). + "vip-next logs": {"app": "a", "env": "e", "type": "t", "limit": "l", "follow": "f"}, + // src/bin/vip-slowlogs.ts:199 — format:true in the factory, then limit. + "vip-next slowlogs": {"app": "a", "env": "e", "format": "f", "limit": "l"}, + + "vip-next app": {"format": "f"}, // src/bin/vip-app.js + "vip-next app list": {"format": "f"}, // src/bin/vip-app-list.js + "vip-next app deploy": {"message": "m", "skip-confirmation": "s", "force": "f", "app": "a", "env": "e"}, + "vip-next app deploy validate": {}, // src/bin/vip-app-deploy-validate.ts + + "vip-next config": {}, + "vip-next config envvar": {}, + "vip-next config envvar list": {"app": "a", "env": "e", "format": "f"}, + "vip-next config envvar get": {"app": "a", "env": "e"}, + "vip-next config envvar get-all": {"app": "a", "env": "e", "format": "f"}, + "vip-next config envvar set": {"app": "a", "env": "e", "from-file": "f", "skip-confirmation": "s"}, + "vip-next config envvar delete": {"app": "a", "env": "e", "skip-confirmation": "s"}, + "vip-next config software": {}, + "vip-next config software get": {"app": "a", "env": "e", "format": "f", "include": "i"}, + "vip-next config software update": {"app": "a", "env": "e", "yes": "y"}, + + "vip-next db": {}, + "vip-next db phpmyadmin": {"app": "a", "env": "e", "print": "p", "silent": "s"}, + + "vip-next cache": {}, + "vip-next cache purge-url": {"app": "a", "env": "e", "from-file": "f"}, + + "vip-next import": {}, + "vip-next import validate-sql": {}, + "vip-next import validate-files": {}, + // src/bin/vip-import-sql.js — --search-replace, --skip-maintenance-mode + // and --header collide with earlier letters and get no short. + "vip-next import sql": {"app": "a", "env": "e", "skip-validate": "s", "in-place": "i", "output": "o", "md5": "m", "skip-backup": "B"}, + "vip-next import sql status": {"app": "a", "env": "e"}, + // src/bin/vip-import-media.js — requireConfirm registers -f/--force. + // vip-next's canonical name for that gate is --skip-confirmation, so the + // short (and the --force spelling) ride on it. + "vip-next import media": {"app": "a", "env": "e", "skip-confirmation": "f", "saveErrorLog": "s", "overwriteExistingFiles": "o", "importIntermediateImages": "i"}, + "vip-next import media status": {"app": "a", "env": "e", "saveErrorLog": "s"}, + "vip-next import media abort": {"app": "a", "env": "e", "skip-confirmation": "f"}, + + "vip-next backup": {}, + "vip-next backup db": {"app": "a", "env": "e"}, + "vip-next export": {}, + "vip-next export sql": {"app": "a", "env": "e", "output": "o", "table": "t", "site-id": "s", + "wpcli-command": "w", "config-file": "c", "generate-backup": "g"}, + + // src/bin/vip-sync.js — requireConfirm -f/--force, same rename as media. + "vip-next sync": {"app": "a", "env": "e", "skip-confirmation": "f"}, + // src/bin/vip-wp.js — DisableFlagParsing; -y/--yes is lifted out of argv + // by normalizeWPArgs, and --app/--env remain the known WP1 limitation. + "vip-next wp": {}, + // src/bin/vip-search-replace.js + "vip-next search-replace": {"search-replace": "s", "in-place": "i", "output": "o"}, + + "vip-next dev-env": {}, + "vip-next dev-env create": {"slug": "s", "title": "t", "multisite": "m", "wordpress": "w", + "mu-plugins": "u", "app-code": "a", "phpmyadmin": "p", "xdebug": "x", "elasticsearch": "e", + "media-redirect-domain": "r", "cron": "c", "mailpit": "A", "photon": "H"}, + "vip-next dev-env update": {"slug": "s", "wordpress": "w", "mu-plugins": "u", "app-code": "a", + "phpmyadmin": "p", "xdebug": "x", "elasticsearch": "e", "media-redirect-domain": "r", + "cron": "c", "mailpit": "A", "photon": "H"}, + "vip-next dev-env start": {"slug": "s", "skip-wp-versions-check": "w", "editor": "e"}, + "vip-next dev-env stop": {"slug": "s", "all": "a"}, + "vip-next dev-env destroy": {"slug": "s"}, + "vip-next dev-env info": {"slug": "s", "all": "a", "extended": "e"}, + "vip-next dev-env list": {}, + "vip-next dev-env purge": {"soft": "s", "force": "f"}, + "vip-next dev-env exec": {"slug": "s", "force": "f", "quiet": "q"}, + "vip-next dev-env shell": {"slug": "s", "root": "r"}, + "vip-next dev-env logs": {"slug": "s", "follow": "f"}, + "vip-next dev-env sync": {}, + "vip-next dev-env sync sql": {"app": "a", "env": "e", "slug": "s", "table": "t", + "wpcli-command": "w", "config-file": "c", "force": "f"}, + "vip-next dev-env envvar": {}, + "vip-next dev-env envvar get": {"slug": "s"}, + "vip-next dev-env envvar get-all": {"slug": "s", "format": "f"}, + "vip-next dev-env envvar list": {"slug": "s", "format": "f"}, + "vip-next dev-env envvar set": {"slug": "s", "from-file": "f"}, + "vip-next dev-env envvar delete": {"slug": "s"}, + "vip-next dev-env import": {}, + "vip-next dev-env import sql": {"slug": "s", "search-replace": "r", "in-place": "i", "skip-reindex": "k", "quiet": "q"}, + "vip-next dev-env import media": {"slug": "s"}, +} + +// goOnlyShorts are shorts on flags Node does not have at all. Every entry is a +// deliberate vip-next extension; anything not listed here and not in +// nodeShortFlags is a regression. +var goOnlyShorts = map[string]map[string]string{ + // vip-next-only repeatable URL mapping for multisite dev-env sync; Node's + // sync sql has no --search-replace, so -r is free on this command. + "vip-next dev-env sync sql": {"search-replace": "r"}, +} + +func walkTree(c *cobra.Command, prefix string, out map[string]*cobra.Command) { + path := strings.TrimSpace(prefix + " " + c.Name()) + out[path] = c + for _, child := range c.Commands() { + walkTree(child, path, out) + } +} + +func commandTree(t *testing.T) map[string]*cobra.Command { + t.Helper() + root := newRootCmd(&rootContext{}) + tree := map[string]*cobra.Command{} + walkTree(root, "", tree) + for _, c := range tree { + // Cobra adds these lazily during execute(); force them so the test + // sees the same flag set a real invocation would. + c.InitDefaultHelpFlag() + c.InitDefaultVersionFlag() + } + return tree +} + +func TestShortFlagAliasesMatchNode(t *testing.T) { + tree := commandTree(t) + + for path, c := range tree { + want, known := nodeShortFlags[path] + if !known { + t.Errorf("command %q has no entry in nodeShortFlags; add one derived from the Node source", path) + continue + } + extra := goOnlyShorts[path] + + local := c.LocalFlags() + for long, short := range want { + f := local.Lookup(long) + if f == nil { + t.Errorf("%s: missing flag --%s (Node exposes -%s, --%s)", path, long, short, long) + continue + } + if f.Shorthand != short { + t.Errorf("%s: --%s shorthand = %q, want %q (Node)", path, long, f.Shorthand, short) + } + } + + local.VisitAll(func(f *pflag.Flag) { + if f.Shorthand == "" { + return + } + if f.Name == "help" || f.Name == "version" { + return // cobra's -h/-v, matching Node's reserved set + } + if want[f.Name] == f.Shorthand || extra[f.Name] == f.Shorthand { + return + } + t.Errorf("%s: --%s carries an unexpected -%s; Node gives it %q", + path, f.Name, f.Shorthand, want[f.Name]) + }) + } + + // Guard against the table rotting when a command is removed. + for path := range nodeShortFlags { + if _, ok := tree[path]; !ok { + names := make([]string, 0, len(tree)) + for p := range tree { + names = append(names, p) + } + sort.Strings(names) + t.Errorf("nodeShortFlags lists %q which is not in the command tree (have %v)", path, names) + } + } +} + +// Node adds -v/--version to EVERY subcommand (command.js:1103-1107), not just +// the root; it prints the version and exits 0. +func TestVersionFlagOnEverySubcommand(t *testing.T) { + for path, c := range commandTree(t) { + if c.Name() == "help" || strings.HasPrefix(c.Name(), "__complete") { + continue // cobra internals, not part of the Node surface + } + f := c.LocalFlags().Lookup("version") + if f == nil { + t.Errorf("%s: no --version flag", path) + continue + } + if f.Shorthand != "v" { + t.Errorf("%s: --version shorthand = %q, want \"v\"", path, f.Shorthand) + } + } +} + +// Node's --debug is `-d, --debug [value]`: bare enables every namespace, +// `--debug=ns1,ns2` scopes it (command.js:557-559 -> +// debugLib.enable(options.debug === true ? '*' : options.debug)). +func TestDebugAcceptsNamespaceList(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"--debug"}, "*"}, + {[]string{"-d"}, "*"}, + {[]string{"--debug=ns1,ns2"}, "ns1,ns2"}, + {[]string{"-d=ns1,ns2"}, "ns1,ns2"}, + } + for _, tc := range cases { + root := newRootCmd(&rootContext{}) + if err := root.ParseFlags(tc.args); err != nil { + t.Fatalf("%q: %v", tc.args, err) + } + got, err := root.Flags().GetString("debug") + if err != nil { + t.Fatalf("%q: --debug is not a string flag: %v", tc.args, err) + } + if got != tc.want { + t.Errorf("%q => --debug %q, want %q", tc.args, got, tc.want) + } + } + + // And it must be inherited by subcommands, as in Node where every bin + // registers it. + root := newRootCmd(&rootContext{}) + c, rest, err := root.Find([]string{"whoami", "-d"}) + if err != nil { + t.Fatal(err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatalf("whoami -d: %v", err) + } +} + +// Node keeps --force on the commands whose confirmation gate came from +// requireConfirm (command.js:1086-1088). vip-next renamed the gate to +// --skip-confirmation; --force must still parse. +func TestForceIsAcceptedAliasOfSkipConfirmation(t *testing.T) { + for _, path := range [][]string{ + {"sync"}, + {"import", "media"}, + {"import", "media", "abort"}, + } { + for _, spelling := range []string{"--force", "--skip-confirmation", "-f"} { + root := newRootCmd(&rootContext{}) + c, rest, err := root.Find(append(append([]string{}, path...), spelling)) + if err != nil { + t.Fatalf("%v: %v", path, err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatalf("vip %s %s: %v", strings.Join(path, " "), spelling, err) + } + v, err := c.Flags().GetBool("skip-confirmation") + if err != nil { + t.Fatalf("vip %s: %v", strings.Join(path, " "), err) + } + if !v { + t.Errorf("vip %s %s did not set the confirmation bypass", strings.Join(path, " "), spelling) + } + } + } +} + +// CUTOVER ITEM 1.4 — a DELIBERATE divergence that must survive the --force +// alias work. In Node --force is a commander boolean, so `--force=false` is +// not recognized as a value form; the truthy string "false" leaks through and +// SKIPS the prompt. vip-next parses it as a real bool, so `--force=false` +// still prompts. Aliasing --force onto --skip-confirmation must not turn it +// into an optional-value/string flag, which would resurrect Node's bug. +func TestForceEqualsFalseStillPrompts(t *testing.T) { + for _, path := range [][]string{{"sync"}, {"import", "media"}, {"import", "media", "abort"}} { + for _, spelling := range []string{"--force=false", "--skip-confirmation=false"} { + root := newRootCmd(&rootContext{}) + argv := prepareArgs(root, append(append([]string{}, path...), spelling)) + c, rest, err := root.Find(argv) + if err != nil { + t.Fatal(err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatalf("vip %s %s: %v", strings.Join(path, " "), spelling, err) + } + // GetBool errors if the gate ever became a string/optional-value + // flag — which is exactly how Node's bug would come back. + v, err := c.Flags().GetBool("skip-confirmation") + if err != nil { + t.Fatalf("vip %s: --skip-confirmation must stay a real bool: %v", + strings.Join(path, " "), err) + } + if v { + t.Errorf("vip %s %s bypassed the prompt; Node's truthy-string bug must NOT be ported", + strings.Join(path, " "), spelling) + } + } + } +} + +// The -a/-e shorthands are command-local flags that SHADOW root's persistent +// --app/--env. If that shadowing broke the @app.env plumbing, every aliased +// invocation would silently lose its target, so pin both directions. +func TestAliasPopulatesTheLocalAppEnvFlags(t *testing.T) { + rc := &rootContext{aliasApp: "myapp", aliasEnv: "develop"} + root := newRootCmd(rc) + c, rest, err := root.Find([]string{"sync"}) + if err != nil { + t.Fatal(err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatal(err) + } + if err := root.PersistentPreRunE(c, rest); err != nil { + t.Fatalf("PersistentPreRunE: %v", err) + } + if got := c.Flag("app").Value.String(); got != "myapp" { + t.Errorf("--app = %q, want %q (alias never reached the leaf)", got, "myapp") + } + if got := c.Flag("env").Value.String(); got != "develop" { + t.Errorf("--env = %q, want %q (alias never reached the leaf)", got, "develop") + } + + // And the alias+flag conflict guard must still fire against the SHORT + // spelling, which only the leaf-local flag can observe. + root2 := newRootCmd(&rootContext{aliasApp: "myapp"}) + c2, rest2, err := root2.Find([]string{"sync", "-a", "other"}) + if err != nil { + t.Fatal(err) + } + if err := c2.ParseFlags(rest2); err != nil { + t.Fatal(err) + } + if err := root2.PersistentPreRunE(c2, rest2); err == nil { + t.Error("alias + -a should be rejected") + } +} + +// src/bin/vip-wp.js registers `--yes`, and createOptionDefinition derives -y +// for it. `vip wp` runs with DisableFlagParsing, so the vip-level token has to +// be lifted out of argv by normalizeWPArgs — the short spelling included. A -y +// that appears AFTER the wp token belongs to the WP-CLI command and must be +// passed through untouched. +func TestNormalizeWPArgsAcceptsShortYes(t *testing.T) { + cases := []struct { + name string + in []string + wantArgv []string + wantYes bool + }{ + {"short yes before dash", []string{"-y", "--", "wp", "user", "list"}, []string{"wp", "user", "list"}, true}, + {"short yes plain", []string{"-y", "wp", "user", "list"}, []string{"wp", "user", "list"}, true}, + {"short yes after wp stays in the WP-CLI command", + []string{"wp", "post", "delete", "1", "-y"}, []string{"wp", "post", "delete", "1", "-y"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotArgv, gotYes := normalizeWPArgs(tc.in) + if strings.Join(gotArgv, " ") != strings.Join(tc.wantArgv, " ") { + t.Errorf("argv = %v, want %v", gotArgv, tc.wantArgv) + } + if gotYes != tc.wantYes { + t.Errorf("yes = %v, want %v", gotYes, tc.wantYes) + } + }) + } +} + +// Regression pin for the optional-value normalizer being wired into the real +// root, not just unit-tested in isolation. +func TestRootAppliesOptionalValueNormalization(t *testing.T) { + root := newRootCmd(&rootContext{}) + argv := prepareArgs(root, []string{"dev-env", "create", "-p", "n", "--xdebug", "n"}) + c, rest, err := root.Find(argv) + if err != nil { + t.Fatal(err) + } + if err := c.ParseFlags(rest); err != nil { + t.Fatalf("parse: %v", err) + } + if v, _ := c.Flags().GetString("phpmyadmin"); v != "n" { + t.Errorf("--phpmyadmin = %q, want \"n\"", v) + } + if v, _ := c.Flags().GetString("xdebug"); v != "n" { + t.Errorf("--xdebug = %q, want \"n\"", v) + } + if n := len(c.Flags().Args()); n != 0 { + t.Errorf("stray positionals after normalization: %q", c.Flags().Args()) + } +} diff --git a/cmd/vip-next/main.go b/cmd/vip-next/main.go new file mode 100644 index 000000000..03e77f0cc --- /dev/null +++ b/cmd/vip-next/main.go @@ -0,0 +1,318 @@ +// Command vip-next is the Go rewrite of @automattic/vip. +// +// Bootstrap order (matches spec §4): +// +// 1. envalias.Rewrite consumes @app.env tokens from os.Args before cobra parses. +// 2. The rewritten argv is handed to a freshly constructed root cobra command. +// 3. Errors and panics route through internal/exit so the process never emits +// a Go stack trace unless --debug is set. +// +// M1 ships --version and --help only; subcommands arrive in M2. +package main + +import ( + "errors" + "log/slog" + "os" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/cmd/vip-next/commands" + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/envalias" + "github.com/Automattic/vip/internal/exit" + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/nodeflags" + "github.com/Automattic/vip/internal/rechallenge" + "github.com/Automattic/vip/internal/telemetry" +) + +func main() { + defer func() { + if r := recover(); r != nil { + if err, ok := r.(error); ok { + exit.WithError(err) + return + } + exit.WithError(panicError{r}) + } + }() + + if err := run(os.Args[1:]); err != nil { + exit.WithError(err) + } +} + +type panicError struct{ v any } + +func (p panicError) Error() string { return "internal error: panic recovered" } + +func run(argv []string) error { + tracker := telemetry.NewDefault() + if tracker == nil { + tracker = &telemetry.Tracker{Disabled: true} + } + exit.RegisterErrorHook(cliErrorHook(tracker)) + return runWithDeps(argv, productionRunDeps(tracker)) +} + +// cliErrorHook builds the exit hook that reports a failed invocation to +// analytics. +// +// NOTE FOR REVIEW — this event has no Node counterpart. The Node CLI registers +// no error hook and never sends error text anywhere, so everything this posts +// to public-api.wordpress.com is surface the Go rewrite added. Errors here +// routinely interpolate absolute local paths, and at least one path (a failed +// presigned download) used to interpolate a live credential, so the text is +// scrubbed before it leaves. See telemetry.ScrubErrorText for what goes. +// +// Scrubbing is the conservative fix. Deleting the hook is the parity-strict +// one; that call belongs to the repo owner, not to this change. +func cliErrorHook(tracker *telemetry.Tracker) func(error) { + return func(err error) { + tracker.TrackEvent("cli_error", map[string]any{ + "error": telemetry.ScrubErrorText(err.Error()), + }) + } +} + +func runWithDeps(argv []string, deps runDeps) error { + rewritten, app, env, err := envalias.Rewrite(argv) + if err != nil { + return err + } + // Check for alias+flag conflict before handing off to cobra, because + // --help short-circuits cobra's PersistentPreRunE pipeline. + if app != "" || env != "" { + if err := checkAliasConflict(rewritten, app, env); err != nil { + return err + } + } + rewritten, wpYes := normalizeWPArgs(rewritten) + commands.SetWPYes(wpYes) + + // rootRef is captured by the rechallenge middleware's Interactive + // closure (set below). The middleware only fires during Execute, so + // rootRef is guaranteed non-nil by the time the closure runs in + // production. The closure falls back to env-only detection if the + // closure somehow fires pre-Execute (shouldn't happen, but defensive). + var rootRef *cobra.Command + executeRoot := func() error { + rc := &rootContext{aliasApp: app, aliasEnv: env} + rootRef = newRootCmd(rc) + rootRef.SetArgs(prepareArgs(rootRef, rewritten)) + return rootRef.Execute() + } + + // Auth bypass: if argv doesn't qualify, require a valid token. Use the + // ORIGINAL argv (not `rewritten`) so the @app.env alias is still present — + // ShouldBypassAuth keys off it to keep aliased dev-env commands (which must + // resolve the app via the API, e.g. `dev-env create` wizard pre-population) + // on the authed path. `rewritten` has the alias stripped, which would + // wrongly bypass them. + apiHost := defaultAPIHost() + k := deps.NewKeychain(apiHost) + store := auth.NewStore(k) + if !auth.ShouldBypassAuth(argv) { + return withAuthenticatedSession(!isNonInteractiveArgv(argv), authBootstrapDeps{ + Keychain: k, + Store: store, + Login: deps.NewLogin(store), + }, func(session *authSession) error { + configureAuthenticated(apiHost, session, deps.Tracker, &rootRef) + return executeRoot() + }) + } + configureBypassed(apiHost, store, deps.Tracker) + return executeRoot() +} + +func configureAuthenticated( + apiHost string, + session *authSession, + tracker *telemetry.Tracker, + rootRef **cobra.Command, +) { + // Share the same keychain backend, but a distinct service name so + // elevated tokens live alongside the primary token. + elevatedKeychain := &keychain.Keychain{ + Backend: session.Keychain.Backend, + Service: rechallenge.ServiceNameForHost(apiHost), + } + elevatedCache := &rechallenge.TokenCache{Keychain: elevatedKeychain} + rechallengeRunner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: apiHost, BearerToken: session.Raw}, + TokenCache: elevatedCache, + } + // On logout (or any token Delete) clear the elevated-token cache. + session.Store.OnDelete = elevatedCache.ClearAll + + // Middleware stack — outermost first, per the M3 contract: + // errorMiddleware → rechallengeMiddleware → retryMiddleware → transport. + middleware := []gql.Middleware{ + gql.NewErrorMiddleware(gql.ErrorConfig{ExitOnError: true}), + gql.NewRechallengeMiddleware(gql.RechallengeConfig{ + TokenCache: elevatedCache, + Runner: rechallengeRunner, + Interactive: func() bool { + if *rootRef == nil { + return rechallenge.IsInteractiveContext(nil) + } + return appctx.IsInteractive(*rootRef) + }, + }), + gql.NewRetryMiddleware(gql.RetryConfig{}), + } + + // Both raw POSTs and genqlient operations flow through the same chain. + gqlHTTPClient := gql.HTTPClientWithMiddleware(apiHost, session.Raw, middleware) + gqlClient := graphql.NewClient(apiHost+"/graphql", gqlHTTPClient) + commands.SetConfig(commands.Config{ + APIHost: apiHost, + Token: session.Raw, + Middleware: middleware, + GQLClient: gqlClient, + Tracker: tracker, + AppCtxConfig: appctx.AppContextConfig{Client: gqlClient}, + }) +} + +// configureBypassed wires the runtime for an invocation that skipped the login +// flow. Node's vip.js bypass is ONLY about the prompt: `runCmd()` still calls +// the API, and src/lib/api/http.ts attaches `Bearer ${(await Token.get()).raw}` +// to every request whatever that token turns out to be — present, absent, +// expired. So a bypassed invocation gets the same client as an authed one, with +// two deliberate differences: +// +// - the token is best-effort. A missing or unreadable credential yields an +// empty bearer and the command 401s, exactly as Node does; it must never +// turn into a hard error here, because `--version` and `--help` reach this +// path on machines that have never logged in. +// - no rechallenge middleware. Step-up approval needs a real session, and +// nothing reachable without a login performs a step-up-guarded mutation. +// +// Before this, bypassed invocations got no GraphQL client at all, so anything +// whose argv merely contained "help"/"login"/"logout"/"-v" — `config envvar get +// help`, `wp help core`, `wp cli version` — died with "GraphQL client not +// configured" instead of running. +func configureBypassed(apiHost string, store *auth.Store, tracker *telemetry.Tracker) { + raw, err := store.Load() + if err != nil { + // Best-effort: ErrNoToken is the common case, and a backend failure + // (locked keyring, no D-Bus) must not break `vip --help`. + slog.Debug("bypassed invocation has no usable token", "err", err) + raw = "" + } + middleware := []gql.Middleware{ + gql.NewErrorMiddleware(gql.ErrorConfig{ExitOnError: true}), + gql.NewRetryMiddleware(gql.RetryConfig{}), + } + gqlClient := graphql.NewClient( + apiHost+"/graphql", + gql.HTTPClientWithMiddleware(apiHost, raw, middleware), + ) + commands.SetConfig(commands.Config{ + APIHost: apiHost, + Token: raw, + Middleware: middleware, + GQLClient: gqlClient, + Tracker: tracker, + AppCtxConfig: appctx.AppContextConfig{Client: gqlClient}, + }) +} + +// defaultAPIHost returns the VIP API host, preferring the API_HOST env var. +func defaultAPIHost() string { + if h := os.Getenv("API_HOST"); h != "" { + return h + } + return "https://api.wpvip.com" +} + +// prepareArgs is the last argv reshaping step before cobra parses. It gives +// cobra commander's optional-value lookahead, which pflag has no equivalent +// for: with a NoOptDefVal set, `dev-env create -p n` would set --phpmyadmin to +// its omitted-value default and drop the "n" on the floor, inverting Node, +// where "n" DISABLES the service. See internal/nodeflags. +func prepareArgs(root *cobra.Command, argv []string) []string { + return nodeflags.NormalizeOptionalValues(root, argv) +} + +// normalizeWPArgs reshapes a post-envalias argv so cobra can route the +// `vip wp` command. wp is special: everything after the `wp` token is a +// raw WP-CLI command (with its own flags) that the wp command passes +// through verbatim (DisableFlagParsing). Two adjustments: +// +// - Strip the FIRST bare "--" separator. The `@app.env -- wp ...` form +// leaves a leading "--" (envalias preserves it); cobra's "--" would +// otherwise block subcommand resolution so root never reaches wp. +// - Extract a standalone "--yes" token that appears before the wp +// command (vip-level flag, e.g. `@app.env --yes -- wp user list`); +// return it separately since DisableFlagParsing keeps cobra from +// parsing it. +// +// Only applies when the invocation targets wp (the first non-flag, +// non-"--" token is "wp"). Everything else passes through untouched. +func normalizeWPArgs(argv []string) (out []string, yes bool) { + cmdIdx := -1 + for i, tok := range argv { + if tok == "--" || strings.HasPrefix(tok, "-") { + continue + } + cmdIdx = i + break + } + if cmdIdx == -1 || argv[cmdIdx] != "wp" { + return argv, false + } + out = make([]string, 0, len(argv)) + dashStripped := false + for i, tok := range argv { + if i >= cmdIdx { + // tokens at cmdIdx onwards (the wp token + the raw WP-CLI + // command, flags and all) are copied verbatim. + out = append(out, tok) + continue + } + if tok == "--" && !dashStripped { + dashStripped = true + continue + } + // Node registers --yes on vip-wp.js and createOptionDefinition + // derives -y for it, so both spellings are vip-level tokens here. + if tok == "--yes" || tok == "-y" { + yes = true + continue + } + out = append(out, tok) + } + return out, yes +} + +// checkAliasConflict returns an error if --app or --env appear in argv when +// an @app.env alias was already parsed (i.e. both sources of app/env are set). +// This mirrors the PersistentPreRunE check in newRootCmd but runs before cobra +// so that --help cannot bypass the guard. +// +// Either alias field (app or env) colliding with either flag (--app or --env) +// is an error, matching Node's rejection of mixed alias+flag usage. +func checkAliasConflict(argv []string, aliasApp, aliasEnv string) error { + hasAlias := aliasApp != "" || aliasEnv != "" + if !hasAlias { + return nil + } + for _, tok := range argv { + if tok == "--" { + break + } + if tok == "--app" || strings.HasPrefix(tok, "--app=") || + tok == "--env" || strings.HasPrefix(tok, "--env=") { + return errors.New("cannot combine @app alias with --app/--env on the same invocation") + } + } + return nil +} diff --git a/cmd/vip-next/main_test.go b/cmd/vip-next/main_test.go new file mode 100644 index 000000000..dadbf9706 --- /dev/null +++ b/cmd/vip-next/main_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "runtime" + "slices" + "strings" + "testing" + + "github.com/Automattic/vip/internal/auth" + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/telemetry" +) + +// buildBinary compiles vip-next into a temp file and returns the path. +// Each test that needs the real binary calls this once. +func buildBinary(t *testing.T) string { + t.Helper() + bin := t.TempDir() + "/vip-next" + if runtime.GOOS == "windows" { + bin += ".exe" // `go build` writes vip-next.exe on Windows; exec needs the real name + } + cmd := exec.Command("go", "build", + "-buildvcs=false", + "-ldflags=-X github.com/Automattic/vip/internal/version.Version=test1.0 -X github.com/Automattic/vip/internal/version.Commit=deadbee", + "-o", bin, + ".") + cmd.Env = os.Environ() + cmd.Stderr = &bytes.Buffer{} + if err := cmd.Run(); err != nil { + t.Fatalf("build: %v\n%s", err, cmd.Stderr) + } + return bin +} + +// runBinary executes the compiled binary with the given args and returns its +// combined output and exit error. DO_NOT_TRACK=1 is injected so telemetry +// construction never touches the OS keychain during tests. +func runBinary(bin string, args ...string) *exec.Cmd { + cmd := exec.Command(bin, args...) + cmd.Env = append(os.Environ(), "DO_NOT_TRACK=1") + return cmd +} + +func TestVersionFlag(t *testing.T) { + bin := buildBinary(t) + out, err := runBinary(bin, "--version").Output() + if err != nil { + t.Fatalf("--version: %v", err) + } + got := string(out) + if !strings.Contains(got, "vip-next test1.0") || !strings.Contains(got, "deadbee") { + t.Errorf("--version output = %q", got) + } +} + +func TestHelpFlag(t *testing.T) { + bin := buildBinary(t) + out, err := runBinary(bin, "--help").Output() + if err != nil { + t.Fatalf("--help: %v", err) + } + if !strings.Contains(string(out), "Usage:") { + t.Errorf("--help missing Usage block: %q", out) + } +} + +func TestAliasStrippedFromArgvSuccess(t *testing.T) { + bin := buildBinary(t) + // `--help` after the alias should print help and exit 0. + // We assert the binary did not error out on the @ token. + cmd := runBinary(bin, "@my-app.staging", "--help") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected exit 0 with alias + --help, got err=%v\n%s", err, out) + } +} + +func TestAliasAndAppFlagConflict(t *testing.T) { + bin := buildBinary(t) + cmd := runBinary(bin, "@my-app", "--app", "other-app", "--help") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected non-zero exit on alias+--app conflict; got 0\n%s", out) + } + if !strings.Contains(string(out), "cannot combine @app alias with --app/--env") { + t.Errorf("unexpected error message: %q", out) + } +} + +func TestAliasAndEnvFlagConflict(t *testing.T) { + bin := buildBinary(t) + cmd := runBinary(bin, "@my-app", "--env", "staging", "--help") + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected non-zero exit on @app + --env conflict; got 0\n%s", out) + } + if !strings.Contains(string(out), "cannot combine @app alias with --app/--env") { + t.Errorf("unexpected error message: %q", out) + } +} + +func TestWhoamiSubcommandRegistered(t *testing.T) { + bin := buildBinary(t) + out, err := runBinary(bin, "whoami", "--help").Output() + if err != nil { + t.Fatalf("whoami --help: %v", err) + } + if !strings.Contains(string(out), "Retrieve details about the current authenticated VIP-CLI user.") { + t.Errorf("whoami help missing description: %q", out) + } +} + +func TestNormalizeWPArgs(t *testing.T) { + cases := []struct { + name string + in []string + wantArgv []string + wantYes bool + }{ + {"plain wp", []string{"wp", "site", "list"}, []string{"wp", "site", "list"}, false}, + {"dash-separated (post-alias)", []string{"--", "wp", "site", "list"}, []string{"wp", "site", "list"}, false}, + {"yes before dash", []string{"--yes", "--", "wp", "user", "list"}, []string{"wp", "user", "list"}, true}, + {"yes plain", []string{"--yes", "wp", "user", "list"}, []string{"wp", "user", "list"}, true}, + {"wp flags preserved", []string{"wp", "post", "list", "--posts_per_page=100"}, []string{"wp", "post", "list", "--posts_per_page=100"}, false}, + {"subshell via dash", []string{"--", "wp"}, []string{"wp"}, false}, + {"non-wp untouched", []string{"app", "list"}, []string{"app", "list"}, false}, + {"non-wp with dash untouched", []string{"--", "app", "list"}, []string{"--", "app", "list"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotArgv, gotYes := normalizeWPArgs(tc.in) + if !slices.Equal(gotArgv, tc.wantArgv) { + t.Errorf("argv = %v, want %v", gotArgv, tc.wantArgv) + } + if gotYes != tc.wantYes { + t.Errorf("yes = %v, want %v", gotYes, tc.wantYes) + } + }) + } +} + +func TestDefensiveModeSubcommandRegistered(t *testing.T) { + bin := buildBinary(t) + out, err := runBinary(bin, "defensive-mode", "--help").Output() + if err != nil { + t.Fatalf("defensive-mode --help: %v", err) + } + for _, want := range []string{"enable", "disable", "configure"} { + if !strings.Contains(string(out), want) { + t.Errorf("help missing subcommand %q: %q", want, out) + } + } +} + +func TestRunResumesCommandAfterAutomaticLogin(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("VIP_TOKEN_OVERRIDE", "") + freshRaw := validBootstrapRaw(t, 10000) + requestCount := 0 + authorization := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + authorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"total":0,"nextCursor":null,"edges":[]}}}`)) + })) + defer srv.Close() + t.Setenv("API_HOST", srv.URL) + + backend := &bootstrapBackend{} + testKeychain := newBootstrapKeychain(backend) + loginCalls := 0 + err := runWithDeps([]string{"app", "list", "--format=json"}, runDeps{ + Tracker: &telemetry.Tracker{Disabled: true}, + NewKeychain: func(string) *keychain.Keychain { + return testKeychain + }, + NewLogin: func(*auth.Store) func() (*auth.Token, error) { + return func() (*auth.Token, error) { + loginCalls++ + return auth.ParseToken(freshRaw) + } + }, + }) + if err != nil { + t.Fatalf("runWithDeps: %v", err) + } + if loginCalls != 1 { + t.Fatalf("login calls = %d, want 1", loginCalls) + } + if requestCount != 1 { + t.Fatalf("GraphQL requests = %d, want 1", requestCount) + } + if authorization != "Bearer "+freshRaw { + t.Fatalf("Authorization = %q, want fresh token", authorization) + } +} diff --git a/cmd/vip-next/root.go b/cmd/vip-next/root.go new file mode 100644 index 000000000..0f4151cb8 --- /dev/null +++ b/cmd/vip-next/root.go @@ -0,0 +1,181 @@ +package main + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/cmd/vip-next/commands" + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/version" +) + +type rootContext struct { + aliasApp string + aliasEnv string +} + +func newRootCmd(rc *rootContext) *cobra.Command { + root := &cobra.Command{ + Use: "vip-next", + Short: "WordPress VIP command-line interface (Go edition)", + Long: "vip-next is the Go rewrite of the @automattic/vip CLI. See https://docs.wpvip.com/.", + SilenceUsage: true, + SilenceErrors: true, + Version: version.String(), + Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, + } + root.SetVersionTemplate(version.String() + "\n") + + // Node registers --debug as `-d, --debug [value]` (command.js:1108-1111) + // and forwards the value to debugLib.enable, using '*' when the flag is + // given without one (command.js:557-559). A cobra Bool rejected the + // namespace form that vip-next's own help advertised. + root.PersistentFlags().StringP("debug", "d", "", "Generate verbose output during command execution. Accepts a comma-separated list of debug namespaces to scope the output (--debug=ns1,ns2).") + root.PersistentFlags().Lookup("debug").NoOptDefVal = "*" + root.PersistentFlags().String("app", "", "target app slug (alternative: @app.env alias)") + root.PersistentFlags().String("env", "", "target environment slug (alternative: @app.env alias)") + // --non-interactive lives on root so every command (and the rechallenge + // middleware, via main.go's closure) can consult appctx.IsInteractive + // against a single, command-tree-wide flag. + root.PersistentFlags().Bool("non-interactive", false, "disable prompts; fail fast if a required flag is missing") + + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + flagApp, _ := cmd.Flags().GetString("app") + flagEnv, _ := cmd.Flags().GetString("env") + hasAlias := rc.aliasApp != "" || rc.aliasEnv != "" + hasFlag := flagApp != "" || flagEnv != "" + if hasAlias && hasFlag { + return errors.New("cannot combine @app alias with --app/--env on the same invocation") + } + if rc.aliasApp != "" { + _ = cmd.Flags().Set("app", rc.aliasApp) + } + if rc.aliasEnv != "" { + _ = cmd.Flags().Set("env", rc.aliasEnv) + } + return nil + } + + root.AddCommand(commands.LoginCmd()) + root.AddCommand(commands.LogoutCmd()) + root.AddCommand(commands.NewWhoamiCmd()) + root.AddCommand(commands.NewDefensiveModeCmd()) + root.AddCommand(commands.LogsCmd()) + root.AddCommand(commands.SlowlogsCmd()) + + appCmd := commands.AppCmd() + appCmd.AddCommand(commands.AppListCmd()) + // `vip app deploy` (+ `validate`) — M7c Custom Deployment. Wired + // before the wildcard so it snapshots `deploy` as a real subcommand. + appDeployCmd := commands.AppDeployCmd() + appDeployCmd.AddCommand(commands.AppDeployValidateCmd()) + appCmd.AddCommand(appDeployCmd) + // `vip app <name>` (positional, no real subcommand) dispatches via the + // wildcard fallback. Must be wired AFTER real subcommands are added so the + // wildcard snapshots the (final) subcommand-name set. + appctx.WithWildcardCommand(appCmd, commands.RunAppGet) + root.AddCommand(appCmd) + + // `vip config envvar list/get/get-all` — read-only M5 commands. Parent + // nodes are bare cobra.Commands; only the leaves wrap WithAppContext + + // WithEnvContext (via buildAppEnvCmd / buildAppEnvRenderableCmd). + configCmd := commands.ConfigCmd() + envvarCmd := commands.ConfigEnvvarCmd() + envvarCmd.AddCommand(commands.ConfigEnvvarListCmd()) + envvarCmd.AddCommand(commands.ConfigEnvvarGetCmd()) + envvarCmd.AddCommand(commands.ConfigEnvvarGetAllCmd()) + // M6 mutation leaves. Production prod-gate is inline (message interpolates + // variable name + app name); --skip-confirmation lives on the leaf via + // WithSkipConfirmationFlag. + envvarCmd.AddCommand(commands.ConfigEnvvarSetCmd()) + envvarCmd.AddCommand(commands.ConfigEnvvarDeleteCmd()) + configCmd.AddCommand(envvarCmd) + // `vip config software get` — M8 read command. `update` will be added in Task 7. + configCmd.AddCommand(commands.ConfigSoftwareCmd()) + root.AddCommand(configCmd) + + // `vip db phpmyadmin` — M5 readonly. Parent `db` is a plain cobra.Command; + // only the leaf wraps WithAppContext + WithEnvContext (via buildAppEnvCmd). + dbCmd := commands.DBCmd() + dbCmd.AddCommand(commands.DBPhpmyadminCmd()) + root.AddCommand(dbCmd) + + // `vip cache purge-url` — M6 mutation. No prompt (cache purge is benign), + // so the leaf only needs the standard WithAppContext + WithEnvContext + // middleware via buildAppEnvCmd. + cacheCmd := commands.CacheCmd() + cacheCmd.AddCommand(commands.CachePurgeURLCmd()) + root.AddCommand(cacheCmd) + + // `vip import validate-sql` — M6b local-only file scanner. No GraphQL + // or appctx middleware; the leaf is a plain cobra command with + // ExactArgs(1). + importCmd := commands.ImportCmd() + importCmd.AddCommand(commands.ImportValidateSQLCmd()) + // `vip import sql` (+ `status`) — M7a heavy command. `status` is a + // SUBCOMMAND of `import sql` (Node: command(...).command('status')). + importSQLCmd := commands.ImportSQLCmd() + importSQLCmd.AddCommand(commands.ImportSQLStatusCmd()) + importCmd.AddCommand(importSQLCmd) + // `vip import media` (+ `status`, `abort`) — M7b heavy commands. + importMediaCmd := commands.ImportMediaCmd() + importMediaCmd.AddCommand(commands.ImportMediaStatusCmd()) + importMediaCmd.AddCommand(commands.ImportMediaAbortCmd()) + importCmd.AddCommand(importMediaCmd) + // `vip import validate-files` — M7b local validator (GraphQL only for + // the mediaImportConfig metadata; no app/env context). + importCmd.AddCommand(commands.ImportValidateFilesCmd()) + root.AddCommand(importCmd) + + // `vip backup db` / `vip export sql` — M7c heavy commands. + backupCmd := commands.BackupCmd() + backupCmd.AddCommand(commands.BackupDBCmd()) + root.AddCommand(backupCmd) + exportCmd := commands.ExportCmd() + exportCmd.AddCommand(commands.ExportSQLCmd()) + root.AddCommand(exportCmd) + + // `vip sync` — M6 mutation. WithChildEnvContext rejects production targets; + // WithRequireConfirm prompts unconditionally before the mutation fires. + // Handler polls SyncProgress to a terminal state. + root.AddCommand(commands.SyncCmd()) + + // `vip wp` — WP1 SSH strategy + subshell. DisableFlagParsing; main.go's + // normalizeWPArgs handles the `--`/`--yes` reshaping. + root.AddCommand(commands.WPCmd()) + + // `vip search-replace <file>` — port of src/bin/vip-search-replace.js. + // Streams a local file through go-search-replace; defaults to STDOUT. + root.AddCommand(commands.SearchReplaceCmd()) + + // `vip dev-env` — full command tree (23 commands) as Node-CLI redirect + // stubs. dev-env (Docker/Lando local dev) is out of scope for vip-next; + // stubs ensure --help and completion show the surface. On the auth-bypass + // list so no login is required. + root.AddCommand(commands.DevEnvCmd()) + + applyVersionToSubcommands(root) + + return root +} + +// applyVersionToSubcommands mirrors Node, which adds -v/--version to EVERY +// subcommand, not just the root (src/lib/cli/command.js:1103-1107 runs for +// every bin, and command.js:553-555 prints the version and exits 0). Cobra +// only synthesizes the flag for commands whose Version field is set, so the +// field is propagated across the tree after wiring; cobra's +// InitDefaultVersionFlag then applies the same "v unless already taken" +// reservation Node's createOptionDefinition does. +func applyVersionToSubcommands(root *cobra.Command) { + tmpl := root.VersionTemplate() + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, child := range c.Commands() { + child.Version = root.Version + child.SetVersionTemplate(tmpl) + walk(child) + } + } + walk(root) +} diff --git a/docs/BUILD-SIGNING.md b/docs/BUILD-SIGNING.md new file mode 100644 index 000000000..f6cd33125 --- /dev/null +++ b/docs/BUILD-SIGNING.md @@ -0,0 +1,376 @@ +# Build and Signing Runbook + +Purpose: build and sign the standalone `vip-next` executable for each supported +platform. + +The binary is a statically linked Go program (`CGO_ENABLED=0`), so **one host can +cross-compile every target**. Only _signing_ is platform-specific: Authenticode +(`signtool`) runs on Windows, `codesign` runs on macOS, and Linux has no +OS-enforced executable signature (publish checksums/detached signatures +instead). + +> This replaces the old Node Single-Executable-Application (SEA) flow. There is +> no `npm run build:sea`, no Node runtime to embed, and no WSL-mediated Windows +> build — Go cross-compiles the `.exe` directly. + +## Prerequisites + +- Go 1.27+ (the tree uses the standard-library `encoding/json/v2` package). +- Build from the repo root. + +## Build + +Native build for the host platform: + +```bash +make build # -> bin/vip-next (bin/vip-next.exe on Windows) +``` + +`make build` is the canonical path. Under the hood it runs, with the version +metadata stamped into `internal/version`: + +```bash +CGO_ENABLED=0 go build -buildvcs=false -trimpath \ + -ldflags="-s -w \ + -X github.com/Automattic/vip/internal/version.Version=$(git describe --tags --always --dirty) \ + -X github.com/Automattic/vip/internal/version.Commit=$(git rev-parse --short HEAD)" \ + -o bin/vip-next ./cmd/vip-next +``` + +To cross-compile any target from any host, set `GOOS`/`GOARCH` and give the +output the right extension: + +```bash +# Windows amd64 (produces a PE the same as a native Windows build) +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -ldflags="-s -w" -o dist/vip-next-windows-amd64.exe ./cmd/vip-next + +# macOS arm64 +GOOS=darwin GOARCH=arm64 ... -o dist/vip-next-darwin-arm64 ./cmd/vip-next + +# Linux amd64 +GOOS=linux GOARCH=amd64 ... -o dist/vip-next-linux-amd64 ./cmd/vip-next +``` + +**Sign after building.** The signature covers the file's bytes (Authenticode +embeds it in the PE certificate table; `codesign` in the Mach-O load commands), +so any rebuild invalidates it. `-s -w -trimpath` are fine — they do not affect +signing. + +Quick smoke checks (only on a host that can execute the target — a +cross-compiled binary won't run on the build machine): + +```bash +bin/vip-next --version +bin/vip-next whoami --help +``` + +## macOS + +Distribution signing with a Developer ID certificate: + +```bash +codesign --remove-signature bin/vip-next +codesign --sign "Developer ID Application: <TEAM/ORG>" --force --options runtime --timestamp bin/vip-next +codesign --verify --strict --verbose=2 bin/vip-next +spctl -a -t exec -vv bin/vip-next # Gatekeeper assessment +``` + +### Notarize + +Signing alone is not enough for public distribution — since macOS 10.15, +Gatekeeper also requires the binary to be **notarized** (scanned and approved by +Apple). Sign first (with `--options runtime`, as above), then submit. + +`notarytool` accepts a `.zip`, `.pkg`, or `.dmg` — not a bare Mach-O — so zip the +signed binary with `ditto` (which preserves the signature), then submit and wait: + +```bash +ditto -c -k --keepParent bin/vip-next bin/vip-next.zip +xcrun notarytool submit bin/vip-next.zip --wait --timeout 30m \ + --key AuthKey_XXXXXXXXXX.p8 --key-id <KEY_ID> --issuer <ISSUER_UUID> +``` + +`--wait` blocks until Apple finishes and exits non-zero unless the result is +`Accepted`; on rejection, read the details with +`xcrun notarytool log <submission-id> --key … --key-id … --issuer …`. + +Authentication is either an **App Store Connect API key** (recommended, shown +above — create it under App Store Connect → Users and Access → Integrations) or +an **Apple ID**: + +```bash +xcrun notarytool submit bin/vip-next.zip --wait \ + --apple-id you@example.com --team-id <TEAMID> --password <app-specific-password> +``` + +**Stapling:** a standalone binary **cannot be stapled** — `xcrun stapler staple` +only works on containers (`.app`, `.pkg`, `.dmg`) that have somewhere to store +the ticket. For a bare CLI binary, Gatekeeper verifies the notarization online at +first run instead. If you later distribute inside a `.pkg`/`.dmg`, staple that +container so it also validates offline. + +## Linux (and WSL) + +Linux — including the binary you run inside WSL — has no universal OS-enforced +Authenticode-style signature. **Do not run `signtool` against the Linux ELF**; +it isn't a PE and there is nothing for Authenticode to sign. + +- The WSL user runs the **Linux** artifact (`vip-next`, no extension), the same + as any native Linux user. Nothing extra is required for WSL. +- A Windows-signed `.exe` will not run as a native Linux binary and vice versa — + they are two separate artifacts. + +Recommended integrity instead: publish a checksum and a detached signature. + +```bash +# SHA-256 checksum +sha256sum bin/vip-next > bin/vip-next.sha256 + +# Detached GPG signature +gpg --armor --detach-sign bin/vip-next + +# …or Sigstore/cosign (keyless) +cosign sign-blob --yes --output-signature bin/vip-next.sig bin/vip-next +cosign verify-blob --signature bin/vip-next.sig bin/vip-next +``` + +## Windows + +Produce the `.exe` (native `make build`, or cross-compile from anywhere with +`GOOS=windows`), then sign in a **Windows** shell — `signtool`/Authenticode are +Windows-only. You can cross-compile the binary from WSL, but run the signing +commands from Windows PowerShell (or via `signtool.exe` over WSL interop if the +SDK is on the Windows PATH). + +`signtool` ships with the **Windows SDK "Signing Tools"** component (install via +`winget install Microsoft.WindowsSDK`, or the standalone SDK). It lands under +`C:\Program Files (x86)\Windows Kits\10\bin\<version>\x64\signtool.exe`. + +Authenticode signing (certificate auto-selected from the Windows cert store): + +```powershell +signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a bin\vip-next.exe +signtool verify /pa /v bin\vip-next.exe +``` + +With a PFX file: + +```powershell +signtool sign /f C:\path\cert.pfx /p <PFX_PASSWORD> /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 bin\vip-next.exe +``` + +Always timestamp (`/tr` + `/td SHA256`) so signatures stay valid after the +certificate expires. + +### No-SDK alternative + +`Set-AuthenticodeSignature` is built into PowerShell and needs no SDK install — +useful for local testing (see below) or on machines without `signtool`: + +```powershell +Set-AuthenticodeSignature -FilePath bin\vip-next.exe -Certificate $cert ` + -HashAlgorithm SHA256 -TimestampServer http://timestamp.digicert.com +Get-AuthenticodeSignature bin\vip-next.exe | Format-List Status, StatusMessage +``` + +### Certificate reality check + +- A **public-CA code-signing certificate** (DigiCert, Sectigo, SSL.com, + GlobalSign, …) is required for users to avoid SmartScreen "unknown publisher" + warnings. A self-signed cert only silences warnings on machines that already + trust it — use it for testing the mechanics, not for release. +- **OV** certs build SmartScreen reputation over downloads/time. **EV** certs get + immediate SmartScreen trust, but since June 2023 the private key must live on + FIPS hardware — a USB token or a cloud signing service (Azure Trusted Signing, + DigiCert KeyLocker, SSL.com eSigner). A plain `.pfx` on disk no longer works + for EV; sign through the provider's KSP (`signtool sign /dlib …` for Azure + Trusted Signing, or `/csp /kc` for a token). +- Since this is an Automattic artifact, check for an existing org signing + certificate / Azure Trusted Signing tenant before buying one. That is what the + `WINDOWS_CERTIFICATE_PFX_BASE64` CI secret is wired for. + +## Local signed-build test (self-signed, Windows) + +This proves the build-and-sign flow end to end without a CA certificate. The +signature is genuine Authenticode; only the certificate is self-signed, so it is +trusted solely on this machine. Run in Windows PowerShell from the repo root: + +```powershell +# 1. Build the exe (cross-compiles fine from WSL too) +$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0' +go build -trimpath -ldflags='-s -w' -o bin\vip-next.exe .\cmd\vip-next + +# 2. Create a throwaway code-signing certificate (CurrentUser, no admin needed) +$cert = New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject 'CN=VIP CLI Test Signing' -CertStoreLocation Cert:\CurrentUser\My -HashAlgorithm SHA256 + +# 3. Trust it for the current user so verification returns Valid +$cerPath = Join-Path $env:TEMP 'vip-cli-test-cert.cer' +Export-Certificate -Cert $cert -FilePath $cerPath | Out-Null +Import-Certificate -FilePath $cerPath -CertStoreLocation Cert:\CurrentUser\Root | Out-Null + +# 4. Sign + verify +Set-AuthenticodeSignature -FilePath bin\vip-next.exe -Certificate $cert ` + -HashAlgorithm SHA256 -TimestampServer http://timestamp.digicert.com +Get-AuthenticodeSignature bin\vip-next.exe | Format-List Status, StatusMessage, SignerCertificate + +# 5. Clean up the throwaway trust anchor (leave no test cert trusted) +Remove-Item "Cert:\CurrentUser\Root\$($cert.Thumbprint)" -Force +Remove-Item "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force +``` + +A `Status` of `Valid` at step 4 confirms the pipeline works. After cleanup the +exe remains signed by the (now-untrusted) test cert — discard it and sign the +real release with a CA certificate. + +## CI automation + +Release builds run on **Buildkite** (Automattic's signing stack), not GitHub +Actions. See `.buildkite/pipeline.yml` and the per-platform scripts. + +- **Pipeline:** `.buildkite/pipeline.yml` — three independent steps (macOS, + Windows, Linux), each building and signing on its own native agent. + `.buildkite/shared-pipeline-vars` is `source`'d before `buildkite-agent +pipeline upload` to supply shared values (toolkit plugin version, Go version, + signing identities). +- **Build scripts:** `.buildkite/build-macos.sh`, `.buildkite/build-windows.ps1`, + `.buildkite/build-linux.sh`. +- **Trigger / gating:** every commit builds + smoke-tests all three platforms; + **signing + notarization run only on tag builds** (gated on `$BUILDKITE_TAG` + inside each script), so notary quota and real certs aren't touched on PRs. +- **macOS certs:** fastlane `match` (`fastlane/Fastfile` → `configure_code_signing`), + `type: developer_id`, stored in S3 (`a8c-fastlane-match`), authenticated with an + App Store Connect API key. Two certs are needed: **Developer ID Application** + (signs the binaries) and **Developer ID Installer** (signs the `.pkg`). +- **macOS artifacts:** two signed + notarized bare binaries (arm64, amd64; + online-verified) **plus** one signed + notarized + **stapled** universal `.pkg` + installer (offline-verified, installs `vip-next` to `/usr/local/bin`). +- **Windows / Linux artifacts:** signed `.exe` (Authenticode via `signtool`) and + the two Linux binaries with `.sha256` checksums. + +### Secrets & values infra owns (`# ← infra:` in the files) + +Buildkite agent queues (`windows`/`default` names), the a8c-ci-toolkit plugin +version, the App Store Connect API key + `match` S3 credentials, the Developer ID +**Installer** certificate (net-new vs the reference — the `.pkg` half depends on +it), and the Windows certificate mechanism (PFX secret vs Azure Trusted Signing +vs hardware token — EV certs can no longer use a plain PFX). + +### Verifying a real run (infra manual gate) + +After the repo is registered in Buildkite, agents are provisioned, and secrets +are wired, run a **tag build** and confirm: + +- `notarytool` result **Accepted** for every submission. +- macOS binaries: `codesign --verify --strict --verbose=2` passes; + `spctl -a -t exec -vv <binary>` assesses as accepted. +- macOS installer: `pkgutil --check-signature <pkg>` shows the Developer ID + Installer chain; `spctl -a -t install -vv <pkg>` accepts; `xcrun stapler +validate <pkg>` confirms the staple is present (offline). +- Windows: `signtool verify /pa /v <exe>` passes. +- Every artifact has a matching `.sha256`. + +## Release checklist + +- Confirm the artifact type matches the target OS (`vip-next` vs `vip-next.exe`). +- Run smoke checks on a host that can execute the produced binary. +- Apply the platform-appropriate signature; **verify** it before publishing. +- Publish checksums (and detached signatures for Linux/macOS). +- Record the signing method and timestamp authority in the release notes. + +--- + +## Bundling `go-search-replace` + +`vip-next` shells out to the `go-search-replace` binary; it never reimplements +it. `searchreplace.ResolveBinary` looks in this order: + +1. `$VIP_SEARCH_REPLACE_BIN` +2. `<executable-dir>/bin/go-search-replace[.exe]` +3. `<executable-dir>/go-search-replace[.exe]` (sibling) +4. `PATH` + +**Decision: we bundle it.** The CLI advertises itself as a self-contained static +binary and `dev-env` depends on working offline, so fetching at install time was +rejected. Cost is ~2.4 MB per platform in the release tarball. + +### How it is vendored + +`third_party/go-search-replace/MANIFEST` pins an upstream release tag and a +sha256 per platform. **Those digests are not ours** — they are the subject +digests from the release's SLSA provenance attestation +(`go-search-replace.intoto.jsonl`), produced by +`Automattic/go-search-replace/.github/workflows/release.yml@refs/tags/<tag>`. + +```bash +make vendor-search-replace # this host's platform +ALL=1 make vendor-search-replace # every platform — the release path +make vendor-search-replace TAG=0.0.12 # upgrade; rewrites MANIFEST +``` + +It downloads, gunzips, verifies against MANIFEST, and **refuses to install on +mismatch** (verified: a corrupted digest exits non-zero and installs nothing). +Binaries are gitignored; only `MANIFEST` is tracked, so an upgrade is one +reviewable commit whose diff is a tag and eight hashes. + +> **Trap:** upstream ships each asset **gzipped** (`<name>.gz`) but the +> provenance attests the **uncompressed** binary. Verify by gunzipping first, +> then hashing. Confirmed against 0.0.11. + +`make build` then resolves in this order, and **fails** if none apply +(escape hatches: `VIP_SEARCH_REPLACE_BIN`, `ALLOW_MISSING_SEARCH_REPLACE=1`): + +1. `third_party/go-search-replace/<goos>-<goarch>/` — all 8 platforms +2. `__fixtures__/search-replace-binaries/` — legacy, 4 platforms only, and part + of the vendored Node mirror, so **nothing may be added there** +3. failure + +Upstream 0.0.11 publishes `darwin_{amd64,arm64}`, `linux_{386,amd64,arm64}`, +`windows_{386,amd64,arm64}` — so `linux/arm64` (Graviton, ARM CI, Docker on +Apple Silicon), previously unsupported, is covered with no self-building. + +### ← infra: what changes in the signing pipeline + +1. **Build agents need the `gh` CLI, authenticated**, for + `make vendor-search-replace`. Alternatively pre-populate + `third_party/go-search-replace/` from an internal mirror — but whatever + supplies it, the MANIFEST check must still run. + +2. **Release builds must run `ALL=1 make vendor-search-replace` before + packaging**, so the tarball is self-contained. Add it ahead of the build step + in `.buildkite/build-*.{sh,ps1}`. + +3. **macOS — this is the one that will bite.** `go-search-replace` is a nested + Mach-O executable inside our distributable. Under the hardened runtime, + notarization **fails** unless every nested executable is signed. So: + + - sign `go-search-replace` with the same Developer ID Application identity as + `vip-next`, with `--options runtime --timestamp`, + - sign it **before** the enclosing `.pkg`/archive is built and submitted, + - staple only the outer artifact. + + Re-signing a third-party binary with our identity is expected for bundled + helpers, but it is a deliberate supply-chain decision: we are attesting a + binary we did not build. The MANIFEST checksum + upstream SLSA provenance is + what makes that defensible — do not weaken either. + +4. **Windows.** Authenticode-signing the bundled helper is optional; nothing + blocks execution if it is unsigned, but SmartScreen reputation is per-binary. + Recommendation: sign it, same cert as `vip-next`. + +5. **Linux.** Nothing extra — the existing checksum/detached-signature step + should cover the bundled helper as well as the main binary. + +6. **Verify after a real run:** the artifact contains + `go-search-replace[.exe]` next to `vip-next`, `codesign -vvv --deep` passes + on macOS, and `vip-next search-replace` works on a machine that never had the + binary on `PATH`. + +### Open + +- Whether to mirror the upstream releases internally rather than depending on + GitHub availability at build time. +- `slsa-verifier` is not yet wired in. The MANIFEST digests were taken from the + provenance by hand for 0.0.11; verifying the attestation signature in CI on + every upgrade would close the loop properly. diff --git a/docs/COMMANDER-MIGRATION.md b/docs/COMMANDER-MIGRATION.md index becbedc12..deea035e9 100644 --- a/docs/COMMANDER-MIGRATION.md +++ b/docs/COMMANDER-MIGRATION.md @@ -5,7 +5,7 @@ Goal: remove the abandoned `args` package, keep CLI behavior stable, and support ## Migration Outcome - `src/lib/cli/command.js` is the active Commander-backed compatibility wrapper for all bins that call `command()`. -- `args` has been removed from `package.json` and `package-lock.json`. +- `args` has been removed from `package.json` and `npm-shrinkwrap.json`. - Root command flow (`src/bin/vip.js`) now dispatches via the shared Commander wrapper again, preserving login gating and subcommand chaining. - Temporary side-path wrapper work has been removed (`src/lib/cli/command-commander.ts` deleted). @@ -16,7 +16,6 @@ Goal: remove the abandoned `args` package, keep CLI behavior stable, and support - `_opts` controls are still honored: app/env context fetch, confirmation gating, output formatting, wildcard command handling, required positional args. - Shared formatting/output and telemetry hooks are still in the wrapper path. - Local nested subcommand dispatch still works via sibling executable resolution. -- Short-option equals normalization: `-x=value` is rewritten to `-x value` for short options expecting values before parsing; boolean short flags are not affected. Tracked option defaults are applied after parsing when the option remains undefined post-parse, preserving default+parser parity. ## Post-Migration Hardening diff --git a/docs/CUTOVER-BREAKING-CHANGES.md b/docs/CUTOVER-BREAKING-CHANGES.md new file mode 100644 index 000000000..20a588599 --- /dev/null +++ b/docs/CUTOVER-BREAKING-CHANGES.md @@ -0,0 +1,258 @@ +# vip-next cutover: breaking changes + +Customer-visible differences between the Node `vip` CLI and the Go `vip-next` rewrite. +**This file is the source for the cutover changelog and migration notes.** Nothing here is +optional to communicate — every entry changes behavior for someone with an existing script. + +Source: the 2026-07-24 adversarial parity review +(`docs/superpowers/notes/2026-07-24-node-go-parity-review.md`, untracked scratch), as amended by +the remediation of the same date (commits `a4d33633`..`1545094c`). + +> **Baseline correction — read before trusting any pre-remediation entry.** The review was +> conducted against this repo's _vendored_ `src/`, which was (a) frozen at 4.0.4 while upstream +> reached 4.1.0, and (b) **hand-edited**: four lines implementing `VIP_TOKEN_OVERRIDE` had been +> injected into `src/lib/token.ts`, plus five matching references in `__tests__/lib/token.js`. +> That variable has never existed in `Automattic/vip` (`git log --all -S` → zero commits). All +> vendored trees are now byte-identical to upstream `trunk`, and +> `TestVendoredNodeSourceHasNoCredentialEscapeHatch` guards against a recurrence. Nine review +> findings have been retired as non-issues; item 2.15 was void and has been deleted. + +Status legend: `KEEP` = intentional divergence, ship it and document it · +`FIX` = regression, must land before cutover · `DONE` = fixed, still needs a changelog line. + +--- + +## 1. Intentional divergences — KEEP and announce + +Go is stricter or more correct than Node here. We are deliberately not carrying the Node +behavior forward, but each one can break an existing script, so each needs a changelog line. + +| # | Change | Node behavior | vip-next behavior | Who breaks | +| ---- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1.1 | Missing required positional | prints help, **exit 0** | error, **exit 1** | `set -e` wrappers that tolerated a usage mistake | +| 1.2 | Unknown `--format` value | silently renders a table, exit 0 | `Invalid format: X`, exit 1 | scripts passing a typo'd/templated format | +| 1.3 | `--format ids` | works (space-joined ids) | rejected on platform commands | `for id in $(vip app list --format ids)` | +| 1.4 | `--flag=false` on optional-value flags | the string `"false"` is truthy, so `--force=false` **skips** the prompt | real boolean, `--force=false` **prompts** | CI templating `--force=${VAR}` | +| 1.5 | `dev-env destroy` | no confirmation at all | prompts unless `--yes` | non-interactive teardown scripts | +| 1.6 | `dev-env purge` in non-TTY | auto-confirms | refuses without `--yes`/`--force` | same | +| 1.7 | `vip wp` without `--` | hard error | accepted | nobody; we lose a helpful diagnostic | +| 1.8 | Remote stderr on `vip wp` (SSH) | silently dropped | forwarded to stderr | anyone redirecting stdout only | +| 1.9 | `app deploy validate` on `.ZIP` | fails (extension compared case-sensitively) | passes | someone who "fixed" a failure by renaming | +| 1.10 | Error output | adds a second space after `Error:`, always writes a runtime `Debug:` line to **stdout**, and may dump the stack when debug namespaces are enabled | one-space `Error:` message on stderr, no runtime banner or stack on stdout | anyone parsing stdout on failure — note this _fixes_ corrupt `--format json` output | +| 1.11 | 401 message | `Unauthorized: undefined; …` | correct default message | — | +| 1.12 | `export sql --site-id=2,3` | `parseInt` → site 2 only (contradicts Node's own docs) | sites 2 and 3 | anyone relying on the buggy narrow export | +| 1.13 | `validate-files` on intermediate images | TypeError → exit 1, no summary | clean summary, exit 0 | CI gating on exit status | +| 1.14 | `import media --overwriteExistingFiles=false` with `--force` | sends truthy string `"false"` | real boolean | — | +| 1.15 | `vip logout` when `POST /logout` fails | unhandled rejection, **exit 1** | best-effort revoke, local token always cleared, **exit 0** | a script asserting logout confirmed server-side revocation | +| 1.16 | `dev-env import sql` validation severity | hard-fails on **every** finding | tiered: platform-policy checks warn and proceed | scripts relying on a non-zero exit for e.g. `ENGINE != InnoDB` | +| 1.17 | `dev-env import sql` siteurl check | warns and imports anyway | **blocks** (exit 1) | importing production SQL with no `--search-replace` | +| 1.18 | Non-interactive `--in-place` search-replace | no non-TTY handling at all | hard error, exit 1 | CI relying on a silent rewrite | +| 1.19 | `dev-env import sql --quiet` | prints the validation report regardless | suppresses the report (warnings/fatals never suppressed) | log scrapers | +| 1.20 | `import media` report download | plain `fetch`, ignores proxy env | honors `VIP_PROXY`/`VIP_USE_SYSTEM_PROXY` | only users who opted in; Node cannot download it at all on a SOCKS-only network | +| 1.21 | `vip config envvar get login` | `isLoginCommand` inverts the bypass and re-runs login even with a valid token | runs normally | anyone depending on that Node defect | +| 1.22 | `import sql` progress ticker on a non-TTY | emits raw cursor escapes with no newlines | silent | log scrapers | +| 1.23 | `import sql --search-replace` **with `--in-place`** | applies the pairs **twice** — the rewritten file is uploaded _and_ the pairs are sent to the server (`vip-import-sql.js:760` is not gated on `isUrl`) | applied once | anyone whose replacement is non-idempotent, e.g. `a,aa` turned "a" into "aaaa". A domain swap hides the bug, which is why it survived. URL imports and local imports _without_ `--in-place` are unchanged. | +| 1.24 | Required prompt in a non-TTY | enquirer emits a raw ANSI prompt; its unresolved promise drains and the process exits 0 without mutating | explicitly reports that prompting is unavailable, prints the command-specific cancellation message, exits 0 without mutating | scripts snapshotting prompt output; mutation safety is unchanged | +| 1.25 | `import validate-sql` line count for a newline-terminated file | counts one phantom trailing line | reports the physical line count | scripts parsing `Finished processing N lines.` | +| 1.26 | `db phpmyadmin --print` streams | progress tracker and warning go to stdout before the URL | stdout contains only the URL; warning/progress go to stderr | command substitution or parsers that previously received progress text with the URL | +| 1.27 | Interactive login banner | legacy uncolored `VIP-CLI` ASCII art | six-line `VIP-CLI 5` ANSI Shadow artwork in the VIP warm-color gradient | snapshot tests or tools scraping the login prompt | + +**Decided exception — do NOT keep:** `config software update` rejecting _deprecated_ versions. +Node accepts them; deprecated versions are exactly what you reach for during an incident +rollback. Fixed — see 2.9. + +--- + +## 2. Regressions — FIX before cutover + +**All 22 original items are resolved.** Each still needs a changelog line. + +| # | Issue | Impact | Status | +| -------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2.1 | dev-env `-p n` / `--xdebug n` / … **enabled** the service (cobra bools vs Node's `y`/`n` value flags); stray `n` silently swallowed | every documented "disable" invocation did the opposite | DONE `61ae9141` | +| 2.2 | `--media-redirect-domain n` stored the literal domain `"n"` | media proxy pointed at a garbage host | DONE `61ae9141` | +| 2.3 | `search-replace --in-place` had no confirmation, and truncated the target before the child result was known | **irreversible rewrite; a rejected pair left a 0-byte file** | DONE `d8081940` | +| 2.4 | `dev-env import sql --in-place` — same missing confirmation | same | DONE `d8081940` | +| 2.5 | `dev-env start` overwrote `<envdir>/.env` | **destroyed all env vars set by the Node CLI** | DONE `d8081940` — `.env` is now the shared source of truth for both CLIs | +| 2.6 | Short flags absent outside dev-env | parse-time failure for most existing scripts | DONE `61ae9141` — restored across ~24 commands | +| 2.7 | `--force` renamed to `--skip-confirmation` | headless invocations failed | DONE `61ae9141` — `--force` restored as an alias | +| 2.8 | `--version` root-only; `-d` / `--debug=ns1,ns2` rejected | version probes and the documented support-debug flow failed | DONE `61ae9141` | +| 2.9 | `config software update` rejected deprecated versions | blocked incident rollback | DONE `5a4a7dac` | +| 2.10 | `IsInteractive()` sensed stdout, not stdin | `vip sync \| tee` cancelled and **exited 0** without mutating | DONE `d8081940` — also fixed a second copy in `rechallenge` | +| 2.11 | Confirmation prompts showed no App/Environment/target detail | users authorized destructive actions blind | DONE `48068fff` | +| 2.12 | `sync` skipped Node's `syncPreview.canSync` pre-flight | fired a destructive mutation Node refuses | DONE `48068fff` | +| 2.13 | `ShouldBypassAuth` scanned the whole argv | `wp help …`, `wp <cmd> --help`, an env var named `help` all failed | DONE `76af73db` — root cause was conflating "skip login" with "skip API setup"; Node's scan is equally flat | +| 2.14 | No `VIP_PROXY`/`SOCKS_PROXY`; `HTTPS_PROXY` honored without opt-in | enterprise users broke; bearer token routed through a declined proxy | DONE `981314cf` + `5d8fe09f` | +| ~~2.15~~ | ~~`VIP_TOKEN_OVERRIDE` honored outside `NODE_ENV=test`~~ | — | **DELETED — VOID.** The variable never existed in Node; it had been hand-injected into the vendored `src/`. This compared Go against fabricated behavior. `33790a72` gated Go's override on `GO_ENV`/`NODE_ENV=test` and was described as parity; it is not, and the gate is retained purely as a Go-only hardening decision (a test hatch should not be a live production auth path). 83 parity scenarios plus `test-parity-unit-hostile` depend on the variable existing. | +| 2.16 | `backup db` / `export sql` polling unbounded | hung forever in CI | DONE `b75e8d82` — 6h `pollUntil` ported; `db phpmyadmin`'s inverse 60s cap also fixed | +| 2.17 | 16 MB per-line scanner cap in SQL validation | rejected large dumps Node imports fine | DONE `5a4a7dac` | +| 2.18 | `export sql --config-file` dropped unknown keys, hard-failed on boolean per-table options | **exported the wrong data scope, exit 0** | DONE `b75e8d82` — Node has no runtime schema at all | +| 2.19 | `slowlogs --limit` capped at 500 | large log pulls failed | DONE `5a4a7dac` — real ceiling is 5000; Node's own help says 500 and its error says 5000 | +| 2.20 | dev-env import/sync skipped Node's post-import steps | **user locked out of their own local wp-admin** | DONE `888051ca` | +| 2.21 | `.wpvip/vip-dev-env.yml` unimplemented | `destroy`/`purge` could target the wrong environment | DONE `888051ca` | +| 2.22 | `dev-env create --start` defaulted true and escalated to `sudo` | CI scripts hung on a sudo prompt | DONE `888051ca` — default now false; the flag itself is unchanged | + +### Also fixed, not in the original list + +| Issue | Impact | Status | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------- | +| `import media` report-download failure leaked the **presigned URL** (its query string is the credential) into the error, which the `cli_error` telemetry hook shipped to `public-api.wordpress.com` | live credential sent off-box on every failed report download | DONE `5d8fe09f` | +| Pendo telemetry endpoint hardcoded to production while `API_HOST` was read on the adjacent line | staging/local runs emitted into the production pipeline | DONE `5d8fe09f` | +| `--non-interactive` did not make step-up fail fast — it polled to session expiry | hung in CI; the catalog claimed the opposite | DONE `8a7ba911` | +| Four exit paths returned 0 where Node returns 1 (`config software update` declined / nil job progress, `dev-env envvar delete` on a missing var, `dev-env purge` on removal failure) | CI reported green on a no-op | DONE `44ece063` | +| `dev-env import sql` ran **no** SQL validation at all; `--skip-validate` was a documented no-op | a dump Node rejects imported clean, exit 0 | DONE `c6defbd0` | + +--- + +## 3. Removed or renamed CLI surface + +Announce these explicitly; they fail at parse time, not at runtime. + +- `--format keyValue` output reshaped: Node emits a `===` banner + `+ key: value`; vip-next emits `key=value` +- `vip logs --format csv|json` no longer emits a leading `__typename` column/key (column indices shift by one) +- `vip logs` table headers are lowercase (`timestamp`/`message`) where Node capitalizes +- `--version` output format: Node prints `4.1.0`; vip-next prints `vip-next <ver> (commit <sha>)` +- `--help` is rendered by Cobra rather than commander: usage and option layout differ, and + Node's appended `Examples` block is not reproduced +- `vip wp` supports only the `@app.env` alias — explicit `--app`/`--env` are ignored (known WP1 limitation) +- No update-notifier: vip-next ships as a signed binary, so there is no in-CLI update channel + +**Restored during remediation — no longer breaking, remove from migration notes:** +`--xdebug_config` (underscore form is canonical again), `dev-env start --vscode`, +`dev-env info --extended`. + +--- + +## 4. New in vip-next (no Node equivalent) + +Reverting to the Node CLI after using these fails loudly (`unknown option`), not silently. + +- `vip completion bash|zsh|fish|powershell`, `vip help` +- Global `--non-interactive` +- `slowlogs --follow` — **note:** `src/bin/vip-slowlogs.ts` defines `followLogs()` and reads `opt.follow` but never registers the option, so Node rejects it. This is Go-only surface, not parity. +- `dev-env sync sql --search-replace`, `dev-env create --domain`/`--start` +- `VIP_RECHALLENGE_WAIT=1` / `--rechallenge-wait` — prints the step-up URL and waits, for non-interactive contexts that can complete a challenge out of band + +**`vip defensive-mode enable|disable|configure` is NOT Go-only.** The review claimed it was; +upstream `trunk` has `src/lib/rechallenge/`, `src/lib/defensive-mode/` and four +`vip-defensive-mode-*` bins. Any "no Node counterpart" reasoning about this subsystem should be +re-checked against trunk. + +**Accepted risk, documented not fixed:** step-up approvals are cached by GraphQL field name +only (`internal/rechallenge/rechallenge.go`), so one "enable WAF on app A production" approval +covers `disable` on app B production until expiry; `CreateSession` sends no app/env, so the +server cannot bind it either. Now that a Node counterpart is known to exist, whether Node binds +app/env is directly checkable and this decision is worth revisiting. + +**Cutover risk worth flagging to stakeholders:** the rechallenge/step-up middleware fires on +_any_ mutation whose response carries `elevated-permission-required`. The moment the API enables +step-up for a mutation Node users need, the Node CLI has no handler and hard-fails — meaning the +ability to revert leaves on the server's schedule, not ours. + +--- + +## 4b. Differential expansion — RESOLVED + +Extending the real Node↔Go differential from 32 to 60 of 85 scenarios (`68007c85`) surfaced +**18 red scenarios** that the former Go-only mock tests could not detect. They are now triaged, +implemented and recorded; `make test-parity-unit` is green. + +Fixed toward Node compatibility: + +- `backup db` keeps progress and success on stdout (including TTY runs), flushes its final + progress frame before the success message, and adds no extra blank line between them. +- `import validate-sql` no longer invents clean/multisite summary lines, and failure findings + now travel through the stderr error path while the line-count header remains on stdout. +- `config envvar set` and `config envvar delete` preserve Node's stdout validation message and + exit 1 without duplicating the same message through the shared stderr renderer. +- `import validate-sql` findings emit Node's failure telemetry with its per-check summary rather + than incorrectly emitting a success event. +- phpMyAdmin enable failures map to Node's stable permission/support messages instead of raw + GraphQL errors; URL-generation failures retain their separate actionable prefix. + +Kept deliberately and moved into the cutover register: clean error rendering (1.10), explicit +non-TTY cancellation (1.24), correct SQL line counts (1.25), URL-only phpMyAdmin `--print` +stdout (1.26), and Cobra help rendering (§3). + +There are now 22 accepted differential scenarios in total: the 15 remaining from this pass +plus seven pre-existing decisions. Every `expected_drift` records both a reason and a SHA-256 +fingerprint of the normalized Node/Go exit code, stdout and stderr. A changed fingerprint or a +stale annotation makes the parity suite red, so an accepted difference cannot mask unrelated +future output drift. + +### Still Go-only-tested (25 of 85) + +Not convertible without new harness machinery, each with a specific reason recorded in +`internal/parity/surface_differential_test.go`: Node's status poll reuses the `App` +operationName (needs variable-shape routing); Node repaints progress every 200 ms with no +non-TTY guard (non-deterministic); call-count-indexed fixtures hand the two CLIs different +worlds; `sync`/`import media` scenarios pass `--skip-confirmation`, which Node does not register +(its gate is `requireConfirm`, which registers only `--force`); `vip wp` needs an SSH/WebSocket +fake speaking Node's protocol. + +**`defensive-mode` IS convertible in principle** — trunk has `src/lib/rechallenge/`, +`src/lib/defensive-mode/` and four bins, so the review's "Go-only surface" claim was wrong. It +needs a Node-shaped rechallenge mock first. + +--- + +## 4c. Live Parker gate — RESOLVED + +`make test-parity` compares the exact 15 allowlisted read-only scenarios against local Parker. +It defaults to the VIP Sys Admin in the canonical seed (`VIP_PARKER_USER_ID=1`) and still accepts +an override after a reseed. Node must already have a matching local-Parker credential in its +stable `vip-go-cli:http---localhost-4000` keychain service; the harness deliberately never writes +or cleans that developer-owned credential. + +The runner removes ambient color controls and pins `TERM=dumb`, so empty `COLORTERM`, CI markers, +or terminal-program hints cannot create ANSI-only Node/Go differences. On 2026-08-18 the live +gate passed `compared=15 equal=15 expected-drift=0` from the normal Codex environment, without +manually unsetting color variables. + +--- + +## 4d. Headless Linux: the keychain fallback notice + +On a host with no D-Bus secret service — a container, a CI runner, a bare SSH +session — `vip-next` cannot reach an OS keyring and stores credentials in a +0600 file instead, announcing it once on stderr: + +``` +warning: OS keyring unavailable; storing credentials in ~/.config/vip/credentials.json (0600) +``` + +The Node CLI uses `configstore` unconditionally and has no equivalent notion, +so it prints nothing. Anyone scripting against `vip` on a headless Linux box +and asserting on empty stderr will see this line appear after cutover. + +Announce it; do not "fix" it by removing the warning. Storing a credential in +a plaintext file is worth saying out loud, and the file backend is a genuine +fallback rather than the intended path. + +The parity harness normalizes this one line away globally +(`ambientStderrRules` in `internal/parity/diff.go`). It has to: the notice +appears in every scenario on Linux and in none on macOS, so left in place it +fails 32 differential scenarios on one platform and zero on the other. That is +a property of the environment, not of any command, which is why it is recorded +here once rather than as 32 per-scenario `expected_drift` entries. + +## 5. Known-broken, carried forward (not cutover blockers) + +- dev-env `sync sql --force` is registered but never read, and sync has no running-environment + gate (Node's is at `src/bin/vip-dev-env-sync-sql.js`). +- `validateImportFileExtension` is not ported to dev-env import — Node rejects anything but + `.sql`/`.gz`. +- `checkAliasConflict` scans argv textually for `--app`/`--env` and will not catch `-a`/`-e`. +- `--skip-confirmation` sits on `import media`'s PersistentFlags, so `import media status` + inherits it; Node has neither. +- `--saveErrorLog` cannot be passed bare (Node registers it as `[value]`). +- Lone-`\r` line endings: Node's readline splits on `/\r?\n|\r(?!\n)/`, Go only on `\n`. +- dev-env WordPress version validation unported — porting Node's naively would reject every + valid version offline. +- `make test-parity` build agents / `gh` auth for `make vendor-search-replace`, and the macOS + nested-binary signing step — see the `← infra:` items in `docs/BUILD-SIGNING.md`. + +_Fixed since this list was written:_ the `--search-replace` double-apply (now 1.23), the +unconditional table ANSI (`internal/output/table.go` now gates on `terminalTableIsTTY`, which is +what took 10 differential scenarios to byte-identical), and the missing `linux/arm64` +`go-search-replace` binary (`third_party/`, checksum-verified). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 6ff7274d2..86dda2fac 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -51,8 +51,6 @@ A few steps should be completed before releasing: 1. You have completed [final testing before deployment](TESTING.md#final-testing-before-releasing). -1. Run the release smoke tests to verify high-risk CLI parser/dispatch commands. First ensure the binaries are built (`npm run build`), then run `npm run smoke:release`. This tests option parsing, short-option equals syntax, and command routing against the built binaries. - 1. The pre-publish [script](https://github.com/Automattic/vip-cli/blob/trunk/helpers/prepublishOnly.js) has been run. This script performs some confidence checks to avoid common mistakes. 1. Finally, release your changes as a [new minor or major NPM version](#releasing-a-new-version). @@ -76,12 +74,6 @@ You can release either using GitHub Actions or locally. This is the preferred method for pushing out the latest release. The workflow runs a bunch of validations, generates a build, bump versions + tags, pushes out to npm, and bumps to the next dev version. -The repository uses `package-lock.json` for npm 12 development and CI installs. An identical -`npm-shrinkwrap.json` is published with npm 11 so npm 11 and older consumers retain a locked CLI -dependency tree. Run `npm run sync:shrinkwrap` after changing `package-lock.json`; CI rejects drift -between the two files. The publish workflows intentionally use npm 11 because npm 12 excludes -`npm-shrinkwrap.json` from package tarballs. - Please keep in mind internal guidelines before releasing. To release, follow these steps: @@ -125,7 +117,7 @@ To publish locally, follow these steps: 1. Push the tag to GitHub (`git push --tags`) 1. Push the trunk branch `git push` 1. Make sure you're part of the Automattic organization in npm -1. Use npm 11 and publish the release (`npx --yes --package=npm@11 npm publish --access public`). The script will do some extra checks ( +1. Publish the release to npm (`npm publish --access public`) the script will do some extra checks ( node version, branch, etc) to ensure everything is correct. If all looks good, the new version will be published and you can proceed. 1. Edit [the release on GitHub](https://github.com/Automattic/vip-cli/releases) to include a description @@ -143,8 +135,8 @@ In order to do that, please follow this: <summary><details> -1. Set the dev version with npm 11 so both lockfiles remain synchronized. Example: `npx --yes --package=npm@11 npm version --no-git-tag-version 1.4.0-dev1`. -1. Run `npx --yes --package=npm@11 npm publish --tag next` (When `--tag` is specified, we bypass the usual branch protection that doesn't allow you to publish form a brunch other than `trunk`). +1. Manually change the version in `package.json` and `package-lock.json` to a dev version. Example: `1.4.0-dev1` +1. Run `npm publish --tag next` (When `--tag` is specified, we bypass the usual branch protection that doesn't allow you to publish form a brunch other than `trunk`). You can repeat this with every new version until you're happy with your version and ready to a public release. We currently don't support multiple branches for multiple versions. When it's the case, this process needs to be done for every version in every branch. diff --git a/docs/SETUP.md b/docs/SETUP.md index d1a5dafaa..17e70f4bb 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -63,7 +63,7 @@ npm run build ## Usage -The software runs as standalone CLI and relies on environment variables for configuration and a few configuration files. +The software runs as standalone CLI and relies on environmental variables for configuration and a few configuration files. ### Starting up locally @@ -85,15 +85,15 @@ By default, we record information about the usage of this tool using an in-house Install the software locally, run and follow the instructions to configure the access token. -### Environment variables +### Environmental variables -#### Configuring environment variables +#### Configuring environmental variables -Environment variables are configured in the shell. Use normal shell commands to set them. +Environmental variables are configured in the shell. Use normal shell commands to set them. -#### List of environment variables +#### List of environmental variables -This application uses environment variables for vital configuration information. Find a partial list below. +This application uses environmental variables for vital configuration information. Find a partial list below. TODO: Update description of the variables. @@ -108,7 +108,6 @@ TODO: Update description of the variables. - `HTTP_PROXY`: - `NO_PROXY`: - `VIP_PROXY`: [For internal VIP use](TESTING.md#local-testing). -- `VIP_RECHALLENGE_WAIT`: set to `1` to print the verification URL and wait for rechallenge completion on another device. - `VIP_USE_SYSTEM_PROXY`: - `WPVIP_DEPLOY_TOKEN`: For use with `vip app deploy` on sites that have custom deploys enabled. diff --git a/docs/TESTING.md b/docs/TESTING.md index e8cba91aa..546d91649 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -64,19 +64,4 @@ VIP_PROXY="" API_HOST=http://localhost:4000 node ./dist/bin/vip -- wp option get ## Final testing before releasing -Before releasing a new version, run the release smoke tests to verify high-risk CLI commands: - -```bash -npm run smoke:release -``` - -This script tests critical parser/dispatch paths for commands like `logs`, `slowlogs`, `wp`, and `dev-env shell`. It verifies: - -- Short-option equals syntax (e.g., `-l=10`) -- Default value explicit matches -- Option-only and separator/subcommand handling -- Command routing without requiring credentials, API access, or Docker/Lando - -The tests target the built `dist/bin/*.js` binaries, so ensure `npm run build` has been run first. All tests use `--help` or other non-executing forms to avoid side effects. - -Exit code 0 indicates all tests passed; non-zero indicates failures that should be reviewed before release. +TODO: How should final testing before releasing be done? diff --git a/docs/dev-env-windows-wsl-testing.md b/docs/dev-env-windows-wsl-testing.md new file mode 100644 index 000000000..64c6a843e --- /dev/null +++ b/docs/dev-env-windows-wsl-testing.md @@ -0,0 +1,191 @@ +# Testing `vip dev-env` on Windows & WSL (offline-domains feature) + +A runbook for building `vip-next` and exercising the offline managed-hosts / +cross-platform elevation feature on a Windows machine — natively and from WSL. + +## TL;DR: you do NOT need to sign the binary + +There are two unrelated "signing" ideas; don't conflate them: + +1. **Authenticode code-signing the `.exe`** (`signtool`, Developer cert, SmartScreen). + This is **only for distribution**. A binary you build yourself and run from your + own terminal does **not** need it. It carries no "mark-of-the-web", so Windows + SmartScreen won't block it. If anything ever warns, "More info → Run anyway". + **For testing this feature, skip signing entirely.** + +2. **The local dev CA** ("WPVIP Local CA") — a self-signed certificate the dev-env + _generates and trusts automatically_ so HTTPS to `*.vipdev.site` is trusted by + your browser. **You don't do this by hand.** When you run `dev-env start`, the + CLI adds that CA to the Windows **Root** store and writes the hosts file — both + under **one UAC prompt** (`certutil -addstore Root` + the hosts edit). That UAC + prompt is the "signing" you were sensing; it's a runtime elevation, not a build + step. + +So: build → run → approve the UAC prompt. That's it. + +## Prerequisites (both paths) + +- **Windows 10/11** with administrator rights (the UAC prompt needs to succeed). +- **Docker Desktop** with the **WSL 2 backend** enabled, running. +- **Go 1.27** (`go version` must report 1.27.x — this repo needs it). +- **Git**. +- The repo cloned somewhere (`git clone …/vip`). + +This repo uses the standard-library `encoding/json/v2` package available in Go 1.27. + +--- + +## Path A — Build & run inside WSL (recommended) + +This exercises the real WSL→Windows bridge: a Linux binary that detects WSL and +edits the **Windows** hosts file + Windows cert store via `powershell.exe`. + +### 1. Install Go 1.27 in your WSL distro + +```bash +# in WSL (Ubuntu etc.) +cd /tmp +curl -LO https://go.dev/dl/go1.27.0.linux-amd64.tar.gz # match 1.27.x +sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.27.0.linux-amd64.tar.gz +echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc && source ~/.bashrc +go version # go1.27.x linux/amd64 +``` + +### 2. Enable Docker Desktop WSL integration + +Docker Desktop → Settings → Resources → WSL Integration → enable your distro. +Confirm in WSL: `docker version` shows both client and server. + +### 3. Confirm the WSL→Windows bridge works (the feature depends on it) + +```bash +powershell.exe -NoProfile -Command "Write-Output ok" # must print: ok +``` + +If `powershell.exe` isn't found, WSL interop is disabled — enable it in +`/etc/wsl.conf` (`[interop] enabled=true`) and `wsl --shutdown` from Windows. + +### 4. Build + +```bash +cd /path/to/vip +CGO_ENABLED=0 go build -buildvcs=false -o bin/vip-next ./cmd/vip-next +./bin/vip-next --version +``` + +(`make build` also works if you have `make`; it additionally bundles the +`go-search-replace` helper, which this feature does not need.) + +### 5. Run it + +```bash +./bin/vip-next dev-env create --slug wintest --start +``` + +When it reaches the hosts/CA step (after WordPress install), a **UAC prompt pops +on the Windows desktop**. Approve it. Behind it: the WPVIP Local CA is added to +the Windows Root store and `wintest.vipdev.site` (+ `-pma`/`-mailpit` if enabled) +is written to the Windows hosts file. + +> Note: env data lives under your WSL home: `~/.local/share/vip/dev-environment/`. + +--- + +## Path B — Build & run natively on Windows (PowerShell) + +The binary is a native `.exe` (`GOOS=windows`) → same Windows-hosts/cert path, +just without the WSL bridge. + +### 1. Install Go 1.27 for Windows + +From <https://go.dev/dl/> (the `.msi`), or `winget install GoLang.Go`. Open a +**new** PowerShell so `go` is on PATH. `go version` → 1.27.x. + +### 2. Build (PowerShell) + +```powershell +cd C:\path\to\vip +$env:CGO_ENABLED = "0" +go build -buildvcs=false -o vip-next.exe .\cmd\vip-next +.\vip-next.exe --version +``` + +(`make` usually isn't present on Windows — use the raw `go build` above.) + +### 3. Run it + +```powershell +.\vip-next.exe dev-env create --slug wintest --start +``` + +Run from an **ordinary** (non-admin) PowerShell — the tool raises its own UAC +prompt for the hosts/cert step. Env data lands at +`C:\Users\<you>\.local\share\vip\dev-environment\`. + +--- + +## Verify the feature worked + +```powershell +# CA is in the Windows Root store: +certutil -store Root | Select-String "WPVIP" + +# Managed hosts block is present (note the BEGIN/END markers): +Get-Content C:\Windows\System32\drivers\etc\hosts | Select-String -Context 0,5 "BEGIN vip-dev-env" + +# The site resolves to loopback and is reachable: +ping wintest.vipdev.site # -> 127.0.0.1 +# then open https://wintest.vipdev.site:<port>/ in a browser (no cert warning) +``` + +From **WSL**, confirm it edited the _Windows_ file (not WSL's `/etc/hosts`): + +```bash +grep -A6 "BEGIN vip-dev-env" /mnt/c/Windows/System32/drivers/etc/hosts +``` + +### The actual offline test + +1. Start the env (hosts written). +2. Turn off Wi-Fi / pull the network (or block DNS). +3. Reload `https://wintest.vipdev.site:<port>/` — it must still resolve, because + the hosts file (not public DNS) is doing the work now. That's the whole point. + +### Re-prompt behavior + +Re-running `dev-env start` on an unchanged env should **not** prompt for UAC again +(the CA is already trusted and the hosts entries already present — the +context-aware `CATrusted` + `HostsPresent` checks short-circuit). If it prompts +every time, that's a bug worth reporting. + +--- + +## Cleanup (to re-test from scratch) + +```powershell +# Remove the managed hosts block: edit the file as Administrator and delete +# everything between "# BEGIN vip-dev-env" and "# END vip-dev-env". +notepad C:\Windows\System32\drivers\etc\hosts # run elevated + +# Remove the CA from the Root store (elevated): +certutil -delstore Root "WPVIP Local CA" +``` + +Or just `vip-next dev-env destroy --slug wintest`, which tears down containers and +recomputes the hosts block (one more UAC prompt). + +--- + +## Known caveats on Windows/WSL + +- The dev-env stack (compose, Traefik proxy, setup.sh) was developed and exercised + primarily on macOS. The hosts/CA elevation path is new and Windows/WSL-aware, but + **other** parts of the stack may hit Windows path/permission quirks not yet seen. + If `create`/`start` fails _before_ the UAC step, capture the per-env log + (`…\dev-environment\wintest\logs\*.log`) — that's a separate issue from this + feature, worth reporting. +- `Start-Process -Verb RunAs` cannot run non-interactively (no desktop session) — + it needs an interactive Windows session to show the UAC dialog. CI/headless runs + won't work; this is a manual test. +- If the elevated step fails or you decline the UAC prompt, the CLI now surfaces a + non-zero exit (it no longer silently reports success), so you'll see an error. diff --git a/docs/vip-next-vip-command-reference.md b/docs/vip-next-vip-command-reference.md new file mode 100644 index 000000000..d5d446586 --- /dev/null +++ b/docs/vip-next-vip-command-reference.md @@ -0,0 +1,257 @@ +# `vip-next` and `vip` command/help inventory + +Generated from the checked-out binaries on 2026-07-15. + +## Scope and reading rules + +- Recursively invoked every help node exposed by `bin/vip-next` and `dist/bin/vip.js` until no new subcommands remained. +- `vip-next`: 72 help nodes including the root (71 non-root command paths). +- `vip`: 61 help nodes including the root (60 non-root command paths). +- Shared help-visible paths: 60. `vip-next`-only help paths: 11. `vip`-only help paths: 0. +- Node help was run with `NODE_ENV=test`, `DO_NOT_TRACK=1`, and a synthetic local JWT via `VIP_TOKEN_OVERRIDE`. This bypassed keychain access; only `--help` was executed and no API payload was sent. +- Angle brackets mean required positional arguments; square brackets mean optional arguments; `...` means repeatable/raw remainder. +- The options column preserves the help text's value shape and constraints. Common flags are listed once below instead of repeated on every row. +- Two accepted forms are not separate help children and are added from source cross-checks: `app <name>` in both CLIs and the Node-only hidden help case for `vip login`. + +## Common invocation forms and flags + +Both CLIs accept an environment alias before `--` as `@app` or `@app.env`; combining an alias with explicit `--app`/`--env` is rejected. + +`vip-next` root/global flags: + +- `--app string`: target app slug. +- `--env string`: target environment slug. +- `--debug`: enable debug logging; help advertises scoped namespaces via `--debug=ns1,ns2`. +- `--non-interactive`: disable prompts and fail fast when required context is missing. +- `-h, --help`; `-v, --version`. + +`vip` flags injected into every help surface: + +- `-h, --help`. +- `-v, --version`. +- `-d, --debug [value]`. + +Unlike `vip-next`, Node's `--app` and `--env` are command-context options rather than root-global flags, so they remain visible in the per-command table. + +## Surface comparison + +| Command path | vip-next | vip | +| ------------------------------------- | --------------------------- | --------------------------- | +| <code>app</code> | help node | help node | +| <code>app <name></code> | wildcard form (parent help) | wildcard form (parent help) | +| <code>app deploy</code> | help node | help node | +| <code>app deploy validate</code> | help node | help node | +| <code>app list</code> | help node | help node | +| <code>backup</code> | help node | help node | +| <code>backup db</code> | help node | help node | +| <code>cache</code> | help node | help node | +| <code>cache purge-url</code> | help node | help node | +| <code>completion</code> | help node | — | +| <code>completion bash</code> | help node | — | +| <code>completion fish</code> | help node | — | +| <code>completion powershell</code> | help node | — | +| <code>completion zsh</code> | help node | — | +| <code>config</code> | help node | help node | +| <code>config envvar</code> | help node | help node | +| <code>config envvar delete</code> | help node | help node | +| <code>config envvar get</code> | help node | help node | +| <code>config envvar get-all</code> | help node | help node | +| <code>config envvar list</code> | help node | help node | +| <code>config envvar set</code> | help node | help node | +| <code>config software</code> | help node | help node | +| <code>config software get</code> | help node | help node | +| <code>config software update</code> | help node | help node | +| <code>db</code> | help node | help node | +| <code>db phpmyadmin</code> | help node | help node | +| <code>defensive-mode</code> | help node | — | +| <code>defensive-mode configure</code> | help node | — | +| <code>defensive-mode disable</code> | help node | — | +| <code>defensive-mode enable</code> | help node | — | +| <code>dev-env</code> | help node | help node | +| <code>dev-env create</code> | help node | help node | +| <code>dev-env destroy</code> | help node | help node | +| <code>dev-env envvar</code> | help node | help node | +| <code>dev-env envvar delete</code> | help node | help node | +| <code>dev-env envvar get</code> | help node | help node | +| <code>dev-env envvar get-all</code> | help node | help node | +| <code>dev-env envvar list</code> | help node | help node | +| <code>dev-env envvar set</code> | help node | help node | +| <code>dev-env exec</code> | help node | help node | +| <code>dev-env import</code> | help node | help node | +| <code>dev-env import media</code> | help node | help node | +| <code>dev-env import sql</code> | help node | help node | +| <code>dev-env info</code> | help node | help node | +| <code>dev-env list</code> | help node | help node | +| <code>dev-env logs</code> | help node | help node | +| <code>dev-env purge</code> | help node | help node | +| <code>dev-env shell</code> | help node | help node | +| <code>dev-env start</code> | help node | help node | +| <code>dev-env stop</code> | help node | help node | +| <code>dev-env sync</code> | help node | help node | +| <code>dev-env sync sql</code> | help node | help node | +| <code>dev-env update</code> | help node | help node | +| <code>export</code> | help node | help node | +| <code>export sql</code> | help node | help node | +| <code>help</code> | help node | — | +| <code>import</code> | help node | help node | +| <code>import media</code> | help node | help node | +| <code>import media abort</code> | help node | help node | +| <code>import media status</code> | help node | help node | +| <code>import sql</code> | help node | help node | +| <code>import sql status</code> | help node | help node | +| <code>import validate-files</code> | help node | help node | +| <code>import validate-sql</code> | help node | help node | +| <code>login</code> | help node | supported, hidden from help | +| <code>logout</code> | help node | help node | +| <code>logs</code> | help node | help node | +| <code>search-replace</code> | help node | help node | +| <code>slowlogs</code> | help node | help node | +| <code>sync</code> | help node | help node | +| <code>whoami</code> | help node | help node | +| <code>wp</code> | help node | help node | + +## `vip-next` command reference + +| Command | Description | Positional / raw arguments | Subcommands | Command-specific options | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| <code>vip-next</code> | vip-next is the Go rewrite of the @automattic/vip CLI. See https://docs.wpvip.com/. | — | <code>app</code><br><code>backup</code><br><code>cache</code><br><code>completion</code><br><code>config</code><br><code>db</code><br><code>defensive-mode</code><br><code>dev-env</code><br><code>export</code><br><code>help</code><br><code>import</code><br><code>login</code><br><code>logout</code><br><code>logs</code><br><code>search-replace</code><br><code>slowlogs</code><br><code>sync</code><br><code>whoami</code><br><code>wp</code> | <code>--app string</code> — target app slug (alternative: @app.env alias)<br><code>--env string</code> — target environment slug (alternative: @app.env alias)<br><code>--non-interactive</code> — disable prompts; fail fast if a required flag is missing | +| <code>vip-next app</code> | Manage VIP Platform applications. Run "vip app list" to list applications, or "vip app <name>" to view information about a specific application and its environments. | <code><name> when not using a named subcommand</code> | <code>deploy</code><br><code>list</code> | <code>--format string</code> — Render output in a particular format. Accepts "table" (default), "csv", "json". (default "table") | +| <code>vip-next app <name></code> | Retrieve information about an application and its environments. Wildcard dispatch form; it is documented by the parent help but is not a child help node. | <code><name></code> | — | <code>--format</code> — table, csv, or json | +| <code>vip-next app deploy</code> | Deploy a local archived file (.zip, .tar.gz, .tgz) that contains application code to a VIP Platform environment that has Custom Deployment enabled. Requires WPVIP_DEPLOY_TOKEN. | <code><file></code> | <code>validate</code> | <code>--force</code> — Skip confirmation prompt (deprecated)<br><code>--message string</code> — Add a description of a deployment.<br><code>--skip-confirmation</code> — Skip the confirmation prompt. | +| <code>vip-next app deploy validate</code> | Validate the directory structure and contents of a local archived file (.zip, .tar.gz, .tgz) ahead of a Custom Deployment. | <code><file></code> | — | — | +| <code>vip-next app list</code> | Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user. | — | — | <code>--format string</code> — Render output in a particular format. (default "table") | +| <code>vip-next backup</code> | Generate database backups for a VIP Platform environment. | — | <code>db</code> | — | +| <code>vip-next backup db</code> | Generate a new database backup of a VIP Platform environment. If a backup is already in progress, the command attaches to it and polls until completion. | — | — | — | +| <code>vip-next cache</code> | Manage edge cache for a VIP Platform environment. | — | <code>purge-url</code> | — | +| <code>vip-next cache purge-url</code> | Purge one or more URLs from the page cache. URLs can be supplied as positional arguments or read from a file via --from-file. When --from-file is used, the file is split on newlines and each line is trimmed; empty lines are dropped. Positional URLs are ignored. | <code>[URL...] or --from-file <path>; at least one URL source is required</code> | — | <code>--from-file string</code> — Read one or more URLs from a file, each listed on a single line. | +| <code>vip-next completion</code> | Generate the autocompletion script for vip-next for the specified shell. See each sub-command's help for details on how to use the generated script. | — | <code>bash</code><br><code>fish</code><br><code>powershell</code><br><code>zsh</code> | — | +| <code>vip-next completion bash</code> | Generate the autocompletion script for the bash shell. This script depends on the 'bash-completion' package. If it is not installed already, you can install it via your OS's package manager. To load completions in your current shell session: source <(vip-next completion bash) To load completions for every new session, execute once: #### Linux: vip-next completion bash > /etc/bash_completion.d/vip-next #### macOS: vip-next completion bash > $(brew --prefix)/etc/bash_completion.d/vip-next You will need to start a new shell for this setup to take effect. | — | — | <code>--no-descriptions</code> — disable completion descriptions | +| <code>vip-next completion fish</code> | Generate the autocompletion script for the fish shell. To load completions in your current shell session: vip-next completion fish | source To load completions for every new session, execute once: vip-next completion fish > ~/.config/fish/completions/vip-next.fish You will need to start a new shell for this setup to take effect. | — | — | <code>--no-descriptions</code> — disable completion descriptions | +| <code>vip-next completion powershell</code> | Generate the autocompletion script for powershell. To load completions in your current shell session: vip-next completion powershell | Out-String | Invoke-Expression To load completions for every new session, add the output of the above command to your powershell profile. | — | — | <code>--no-descriptions</code> — disable completion descriptions | +| <code>vip-next completion zsh</code> | Generate the autocompletion script for the zsh shell. If shell completion is not already enabled in your environment you will need to enable it. You can execute the following once: echo "autoload -U compinit; compinit" >> ~/.zshrc To load completions in your current shell session: source <(vip-next completion zsh) To load completions for every new session, execute once: #### Linux: vip-next completion zsh > "${fpath[1]}/\_vip-next" #### macOS: vip-next completion zsh > $(brew --prefix)/share/zsh/site-functions/\_vip-next You will need to start a new shell for this setup to take effect. | — | — | <code>--no-descriptions</code> — disable completion descriptions | +| <code>vip-next config</code> | Manage configuration for a VIP Platform environment. | — | <code>envvar</code><br><code>software</code> | — | +| <code>vip-next config envvar</code> | Manage environment variables for a VIP Platform environment. | — | <code>delete</code><br><code>get</code><br><code>get-all</code><br><code>list</code><br><code>set</code> | — | +| <code>vip-next config envvar delete</code> | Permanently delete an environment variable from the target environment. | <code><VARIABLE_NAME></code> | — | <code>--skip-confirmation</code> — Skip confirmation prompts. | +| <code>vip-next config envvar get</code> | Retrieve the value of a specific environment variable. | <code><VARIABLE_NAME></code> | — | — | +| <code>vip-next config envvar get-all</code> | Retrieve a list of all environment variables and their values. | — | — | <code>--format string</code> — Render output in a particular format. (default "table") | +| <code>vip-next config envvar list</code> | List the names of all environment variables on an environment. | — | — | <code>--format string</code> — Render output in a particular format. (default "table") | +| <code>vip-next config envvar set</code> | Add or update an environment variable. The value can be passed via --from-file=<path> or entered at a masked prompt. | <code><VARIABLE_NAME></code> | — | <code>--from-file string</code> — Read the value from a file (Node parity: data.trim() strips surrounding whitespace).<br><code>--skip-confirmation</code> — Skip confirmation prompts. | +| <code>vip-next config software</code> | Manage software settings (WordPress, PHP, Node.js, MU Plugins) for a VIP Platform environment. | — | <code>get</code><br><code>update</code> | — | +| <code>vip-next config software get</code> | Retrieve software settings for a VIP Platform environment. Optionally filter to a single component by passing its slug as a positional argument. | <code>[wordpress|php|nodejs|muplugins]</code> | — | <code>--format string</code> — Render output in a particular format. (default "table")<br><code>--include stringArray</code> — Retrieve additional data of a specific type. Supported values: available_versions | +| <code>vip-next config software update</code> | Update a software component (wordpress, php, muplugins, nodejs) to the specified version for a VIP Platform environment. Note: both <component> and <version> are required positional arguments. Node.js apps support only the 'nodejs' component; WordPress apps support 'wordpress', 'php', and 'muplugins'. | <code><component> <version></code> | — | <code>--yes</code> — Skip the confirmation prompt | +| <code>vip-next db</code> | Database operations for a VIP Platform environment. | — | <code>phpmyadmin</code> | — | +| <code>vip-next db phpmyadmin</code> | Generate access to a read-only phpMyAdmin web interface for the environment's database. By default the URL is opened in your browser. Use --print to write it to stdout instead. | — | — | <code>--print</code> — Print the phpMyAdmin URL to stdout instead of opening it in a browser.<br><code>--silent</code> — Do not print any output to the console. | +| <code>vip-next defensive-mode</code> | Enable, disable, or configure WAF defensive mode for an environment. Mutations on production require step-up authentication. | — | <code>configure</code><br><code>disable</code><br><code>enable</code> | <code>--skip-confirmation</code> — Skip the production confirmation prompt. | +| <code>vip-next defensive-mode configure</code> | Update the defensive mode configuration for the target environment. Use --enabled and --challenge-type as required flags. | — | — | <code>--challenge-type string</code> — Challenge type integer. Required.<br><code>--connection-threshold-absolute string</code> — Absolute connection threshold.<br><code>--connection-threshold-percentage string</code> — Connection threshold percentage.<br><code>--enabled string</code> — Whether defensive mode should be enabled (true|false). Required.<br><code>--skip-confirmation</code> — Skip the production confirmation prompt. <em>(inherited)</em> | +| <code>vip-next defensive-mode disable</code> | Disable WAF defensive mode for the target environment. Step-up auth is required on production. | — | — | <code>--skip-confirmation</code> — Skip the production confirmation prompt. <em>(inherited)</em> | +| <code>vip-next defensive-mode enable</code> | Enable WAF defensive mode for the target environment. Step-up auth is required on production. | — | — | <code>--skip-confirmation</code> — Skip the production confirmation prompt. <em>(inherited)</em> | +| <code>vip-next dev-env</code> | Manage a local VIP development environment | — | <code>create</code><br><code>destroy</code><br><code>envvar</code><br><code>exec</code><br><code>import</code><br><code>info</code><br><code>list</code><br><code>logs</code><br><code>purge</code><br><code>shell</code><br><code>start</code><br><code>stop</code><br><code>sync</code><br><code>update</code> | — | +| <code>vip-next dev-env create</code> | Create a new local environment | — | — | <code>-a, --app-code string</code> — Local path to application code (empty = demo image).<br><code>-c, --cron</code> — Enable cron.<br><code>--domain string</code> — Custom domain (empty = vipdev.lndo.site).<br><code>-e, --elasticsearch</code> — Enable Elasticsearch.<br><code>-A, --mailpit</code> — Enable Mailpit.<br><code>-r, --media-redirect-domain string</code> — Redirect uploads to this domain.<br><code>-u, --mu-plugins string</code> — Local path to mu-plugins (empty = image).<br><code>-m, --multisite string</code> — Multisite mode: "subdomain", "subdirectory", or "" for single site.<br><code>-H, --photon</code> — Enable Photon.<br><code>--php string</code> — PHP image/version.<br><code>-p, --phpmyadmin</code> — Enable phpMyAdmin.<br><code>-s, --slug string</code> — A unique name for the new local environment.<br><code>--start</code> — Start the environment after creating it. (default true)<br><code>-t, --title string</code> — WordPress Site Title.<br><code>-w, --wordpress string</code> — WordPress version tag.<br><code>-x, --xdebug</code> — Enable Xdebug.<br><code>--xdebug-config string</code> — Override the default Xdebug configuration. | +| <code>vip-next dev-env destroy</code> | Remove a local environment | — | — | <code>-s, --slug string</code> — A unique name for a local environment.<br><code>--soft</code> — Preserve the environment's configuration files so it can be recreated.<br><code>--yes</code> — Skip the confirmation prompt. | +| <code>vip-next dev-env envvar</code> | Manage environment variables for a local environment | — | <code>delete</code><br><code>get</code><br><code>get-all</code><br><code>list</code><br><code>set</code> | — | +| <code>vip-next dev-env envvar delete</code> | Delete a variable | <code><name></code> | — | <code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env envvar get</code> | Get a variable | <code><name></code> | — | <code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env envvar get-all</code> | Get all variables | — | — | <code>-f, --format string</code> — Render output in a particular format: table, csv, json, or ids. (default "table")<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env envvar list</code> | List variable names | — | — | <code>-f, --format string</code> — Render output in a particular format: table, csv, json, or ids. (default "table")<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env envvar set</code> | Set a variable | <code><name> [value]</code> | — | <code>-f, --from-file string</code> — Read the variable value from a UTF-8 text file (useful for multiline values).<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env exec</code> | Run a WP-CLI command. A double dash ("--") must separate vip args from the wp command: vip dev-env exec --slug=example -- wp post list | <code>-- <wp-cli command and args...></code> | — | <code>-f, --force</code> — Skip the running-environment check.<br><code>-q, --quiet</code> — Suppress informational messages.<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env import</code> | Import data into a local environment | — | <code>media</code><br><code>sql</code> | — | +| <code>vip-next dev-env import media</code> | Import media files into a local environment | <code><directory></code> | — | <code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env import sql</code> | Import a SQL file into a local environment | <code><file></code> | — | <code>-i, --in-place</code> — Search-replace the source SQL file in place (saves the changes).<br><code>-q, --quiet</code> — Skip confirmation and suppress informational messages.<br><code>-r, --search-replace stringArray</code> — "from,to" replacement applied during import (repeatable).<br><code>-k, --skip-reindex</code> — Skip the Elasticsearch reindex after import.<br><code>--skip-validate</code> — Skip SQL file validation.<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env info</code> | Show information about a local environment | — | — | <code>-a, --all</code> — Show information about all local environments.<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env list</code> | List local environments | — | — | — | +| <code>vip-next dev-env logs</code> | Show logs for a local environment | — | — | <code>-f, --follow</code> — Continually output logs as they are generated.<br><code>--service string</code> — Restrict to a single service.<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env purge</code> | Remove all local environments and shared services | — | — | <code>-f, --force</code> — Skip the confirmation prompt (alias of --yes).<br><code>-s, --soft</code> — Preserve every environment's configuration files.<br><code>--yes</code> — Skip the confirmation prompt. | +| <code>vip-next dev-env shell</code> | Open a shell in a local environment | <code>-- <shell command and args...></code> | — | <code>-r, --root</code> — Open the shell with root privileges.<br><code>--service string</code> — Restrict to a single service (default php).<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env start</code> | Start a local environment | — | — | <code>-e, --editor string</code> — Generate an editor workspace file (vscode, cursor, or windsurf).<br><code>--skip-confirmation</code> — Skip confirmation prompts.<br><code>--skip-rebuild</code> — Only start services that are not already in a running state.<br><code>-w, --skip-wp-versions-check</code> — Skip the WordPress version check (accepted; the Go port has no such prompt).<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env stop</code> | Stop a local environment | — | — | <code>-a, --all</code> — Stop all local environments.<br><code>-s, --slug string</code> — A unique name for a local environment. | +| <code>vip-next dev-env sync</code> | Sync a VIP Platform environment into a local environment | — | <code>sql</code> | — | +| <code>vip-next dev-env sync sql</code> | Sync the database of a VIP Platform environment to a local environment | — | — | <code>-c, --config-file string</code> — Local configuration file specifying the data to sync.<br><code>-f, --force</code> — Skip validations (e.g. the running-environment check).<br><code>-r, --search-replace stringArray</code> — Map a source URL or domain to a routable local target; repeatable (source,target).<br><code>--site-id stringArray</code> — Network site id to include in a partial sync (repeatable, or comma-separated).<br><code>-s, --slug string</code> — A unique name for a local environment.<br><code>-t, --table stringArray</code> — Table to include in a partial sync (repeatable, or comma-separated).<br><code>-w, --wpcli-command string</code> — Custom WP-CLI command that retrieves the data for a partial export. | +| <code>vip-next dev-env update</code> | Update a local environment | — | — | <code>-a, --app-code string</code> — Local path to application code.<br><code>-c, --cron</code> — Enable cron.<br><code>-e, --elasticsearch</code> — Enable Elasticsearch.<br><code>-A, --mailpit</code> — Enable Mailpit.<br><code>-r, --media-redirect-domain string</code> — Redirect uploads to this domain.<br><code>-u, --mu-plugins string</code> — Local path to mu-plugins.<br><code>-H, --photon</code> — Enable Photon.<br><code>--php string</code> — PHP image/version.<br><code>-p, --phpmyadmin</code> — Enable phpMyAdmin.<br><code>-s, --slug string</code> — A unique name for a local environment.<br><code>-w, --wordpress string</code> — WordPress version tag.<br><code>-x, --xdebug</code> — Enable Xdebug.<br><code>--xdebug-config string</code> — Override the default Xdebug configuration. | +| <code>vip-next export</code> | Export data (SQL database backups) from a VIP Platform environment. | — | <code>sql</code> | — | +| <code>vip-next export sql</code> | Download an archived copy of the most recent database backup for a VIP Platform environment, or generate and download a partial database export. | — | — | <code>--config-file string</code> — A local configuration file that specifies the data to include in the partial database export. Accepts a relative or absolute path to the file.<br><code>--generate-backup</code> — Generate a fresh database backup and export an archived copy of that backup.<br><code>--output string</code> — Download the file to a specific local directory path with a custom file name.<br><code>--site-id stringArray</code> — The ID of a network site to include in the partial database export. Accepts an integer value and can be passed more than once with a different value, or add multiple values in a comma-separated list.<br><code>--skip-download</code> — Skip downloading the file.<br><code>--table stringArray</code> — The name of a table to include in the partial database export. Accepts a string value and can be passed more than once with a different value, or add multiple values in a comma-separated list.<br><code>--wpcli-command string</code> — Run a custom WP-CLI command that has logic to retrieve specific data for the partial database export. | +| <code>vip-next help</code> | Help provides help for any command in the application. Simply type vip-next help [path to command] for full details. | — | — | — | +| <code>vip-next import</code> | Validate and import data (SQL dumps, media files) into a VIP Platform environment. | — | <code>media</code><br><code>sql</code><br><code>validate-files</code><br><code>validate-sql</code> | — | +| <code>vip-next import media</code> | Import an archive of media files (.tar.gz, .tgz, .zip) from a local path or a publicly accessible URL into a VIP Platform environment. The command polls the import status until completion. | <code><file|url></code> | <code>abort</code><br><code>status</code> | <code>--exportFileErrorsToJson</code> — Format the error log in JSON. Default is TXT.<br><code>--importIntermediateImages</code> — Include intermediate image files in the import.<br><code>--overwriteExistingFiles</code> — Overwrite existing files with the imported files if they have the same path and file name.<br><code>--saveErrorLog string</code> — Skip the confirmation prompt and download an error log for the import automatically.<br><code>--skip-confirmation</code> — Skip confirmation prompts. | +| <code>vip-next import media abort</code> | Abort the media file import that is currently in progress on an environment. The import process cannot be resumed. | — | — | <code>--skip-confirmation</code> — Skip confirmation prompts. | +| <code>vip-next import media status</code> | Check the status of a currently running media import or retrieve an error log of the most recent media import. If the import is still in progress, the command will poll until the import is complete. | — | — | <code>--exportFileErrorsToJson</code> — Format an error log in JSON. Default is TXT.<br><code>--saveErrorLog string</code> — Skip the confirmation prompt and download an error log automatically. (default "prompt")<br><code>--skip-confirmation</code> — Skip confirmation prompts. <em>(inherited)</em> | +| <code>vip-next import sql</code> | Import a local or remote SQL database file into a VIP Platform environment. Local files are validated and uploaded; remote files are fetched by the platform. The command polls the import status until completion. | <code><file|url></code> | <code>status</code> | <code>--header stringArray</code> — Pass a header name and value (Formatted as "Name: Value") in a request for a remote SQL database file. Can be passed more than once for multiple headers and values.<br><code>--in-place</code> — Overwrite a local SQL database file with the results of a search and replace operation prior to import.<br><code>--md5 string</code> — Verify the integrity of a remote SQL database file. Accepts an MD5 hash value.<br><code>--output string</code> — Save the results of a --search-replace operation that is run against a local SQL database file to a copy of that file. Accepts a local file path. Ignored when used with the --in-place option.<br><code>--search-replace stringArray</code> — Search for a string in a local or remote SQL database file and replace it with a new string. Separate the values by a comma only; no spaces (e.g. --search-replace="from,to"). Can be passed more than once.<br><code>-B, --skip-backup</code> — Skip creating a backup before importing the SQL file. WARNING: This is extremely dangerous and can result in permanent data loss.<br><code>--skip-maintenance-mode</code> — Prevent an unlaunched environment from going into maintenance mode during the import of a local or remote SQL database file. Skipping maintenance mode can cause site instability during import.<br><code>--skip-validate</code> — Do not perform file validation prior to import. If the file contains unsupported entries, the import is likely to fail. | +| <code>vip-next import sql status</code> | Check the status of the most recent SQL database import to an environment. If the import is still in progress, the command will poll until the import is complete. | — | — | — | +| <code>vip-next import validate-files</code> | Validate the directory structure, file extensions, file names, and file sizes of a local directory of media files against the WordPress VIP recommended structure (`uploads/year/month`, or `uploads/sites/<siteID>/year/month` for multisites). | <code><folder></code> | — | — | +| <code>vip-next import validate-sql</code> | Scan a local SQL file for syntactically valid but platform-incompatible statements (e.g. DROP DATABASE, TRIGGER, ALTER USER, non-InnoDB ENGINE) plus detect whether the dump is from a WordPress multisite installation. Mirrors Node's `vip import validate-sql` (src/lib/validations/sql.ts). Compressed files (.gz) are not supported — extract first and re-run. | <code><FILE></code> | — | — | +| <code>vip-next login</code> | Authenticate your installation of VIP-CLI with your Personal Access Token. | — | — | — | +| <code>vip-next logout</code> | Log out the current authenticated VIP-CLI user | — | — | — | +| <code>vip-next logs</code> | Retrieve application or batch runtime logs for a VIP Platform environment. | — | — | <code>--follow</code> — Output new entries as they are generated.<br><code>--format string</code> — Render output in a particular format. (default "table")<br><code>--limit int</code> — Maximum number of entries to return (1..5000). (default 500)<br><code>--type string</code> — Type of logs to retrieve. Accepts "app" or "batch". (default "app") | +| <code>vip-next search-replace</code> | Search and replace strings in a local file | <code><file></code> | — | <code>--in-place</code> — Overwrite the local input file with the results.<br><code>--output string</code> — Local file path to save the results (ignored with --in-place).<br><code>--search-replace stringArray</code> — A comma-separated pair of strings (e.g. --search-replace="from,to"). | +| <code>vip-next slowlogs</code> | Retrieve MySQL slow-query log entries for a VIP Platform environment. | — | — | <code>--follow</code> — Output new entries as they are generated.<br><code>--format string</code> — Render output in a particular format. (default "table")<br><code>--limit int</code> — Maximum number of entries to return (1..500). (default 500) | +| <code>vip-next sync</code> | Trigger a data sync from the production environment of an app into one of its child environments (develop, staging, ...). Production is the source and is therefore not a valid target. | — | — | <code>--skip-confirmation</code> — Skip confirmation prompts. | +| <code>vip-next whoami</code> | Retrieve details about the current authenticated VIP-CLI user. | — | — | — | +| <code>vip-next wp</code> | Run a WP-CLI command on a VIP Platform environment, or launch an interactive WP-CLI shell when no command is given. | <code>[wp-cli command and args...]; --yes is a vip-level flag extracted before raw pass-through</code> | — | — | + +## `vip` command reference + +| Command | Description | Positional / raw arguments | Subcommands | Command-specific options | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| <code>vip</code> | The VIP JavaScript library and CLI. | — | <code>logout</code><br><code>app</code><br><code>backup</code><br><code>cache</code><br><code>config</code><br><code>dev-env</code><br><code>export</code><br><code>import</code><br><code>logs</code><br><code>search-replace</code><br><code>slowlogs</code><br><code>db</code><br><code>sync</code><br><code>whoami</code><br><code>wp</code> | — | +| <code>vip login</code> | Authenticate VIP-CLI with a Personal Access Token. This is accepted by the root auth flow but omitted from the Node help tree; vip login --help enters the interactive login flow. | — | — | — | +| <code>vip app</code> | Interact with applications that the current authenticated VIP-CLI user has permission to access. | <code><name> when not using a named subcommand</code> | <code>list</code><br><code>deploy</code> | <code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“. | +| <code>vip app <name></code> | Retrieve information about an application and its environments. Wildcard dispatch form; it is documented by the parent help but is not a child help node. | <code><name></code> | — | <code>--format</code> — table, csv, or json | +| <code>vip app deploy</code> | Deploy an archived file of application code to an environment that has Custom Deployment enabled. | <code><file></code> | <code>validate</code> | <code>-m, --message [value]</code> — Add a description of a deployment.<br><code>-s, --skip-confirmation [value]</code> — Skip the confirmation prompt.<br><code>-f, --force [value]</code> — Skip confirmation prompt (deprecated)<br><code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type. | +| <code>vip app deploy validate</code> | Validate the directory structure of an archived file. | <code><file></code> | — | — | +| <code>vip app list</code> | Retrieve a list of applications that can be accessed by the current authenticated VIP-CLI user. | — | — | <code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“. | +| <code>vip backup</code> | Generate a backup of an environment. | — | <code>db</code> | — | +| <code>vip backup db</code> | Generate a new database backup of an environment. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type. | +| <code>vip cache</code> | Manage page cache for an environment. | — | <code>purge-url</code> | — | +| <code>vip cache purge-url</code> | Purge page cache for one or more URLs. | <code>[URL...] or --from-file <path>; at least one URL source is required</code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --from-file [value]</code> — Read one or more URLs from a file, each listed on a single line. | +| <code>vip config</code> | Manage environment configurations. | — | <code>envvar</code><br><code>software</code> | — | +| <code>vip config envvar</code> | Manage environment variables for an environment. | — | <code>delete</code><br><code>get</code><br><code>get-all</code><br><code>list</code><br><code>set</code> | — | +| <code>vip config envvar delete</code> | Delete an environment variable. | <code><VARIABLE_NAME></code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-s, --skip-confirmation</code> — Skip the confirmation prompt (USE WITH CAUTION). (default: false) | +| <code>vip config envvar get</code> | Retrieve the value of an environment variable. | <code><VARIABLE_NAME></code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type. | +| <code>vip config envvar get-all</code> | Retrieve the names and values of all environment variables. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“. | +| <code>vip config envvar list</code> | List the names of all environment variables. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“. | +| <code>vip config envvar set</code> | Add or update an environment variable. | <code><VARIABLE_NAME></code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --from-file [value]</code> — Read environment variable value from a UTF-8-encoded text file (useful for multiline input). Accepts a relative or absolute path.<br><code>-s, --skip-confirmation</code> — Skip the confirmation prompt (USE WITH CAUTION). (default: false) | +| <code>vip config software</code> | Manage versions of software for an environment. | — | <code>get</code><br><code>update</code> | — | +| <code>vip config software get</code> | Retrieve the current versions of environment software. | <code><wordpress|php|nodejs|muplugins></code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.<br><code>-i, --include [value]</code> — Retrieve additional data of a specific type. Supported values: available_versions | +| <code>vip config software update</code> | Update the version of software running on an environment. | <code><wordpress|php|nodejs|muplugins> <version></code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-y, --yes [value]</code> — Skip the confirmation prompt and automatically submit "y". | +| <code>vip db</code> | Access an environment's database. | — | <code>phpmyadmin</code> | — | +| <code>vip db phpmyadmin</code> | Generate access to a read-only phpMyAdmin web interface for an environment database. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-p, --print [value]</code> — Print the phpMyAdmin URL to stdout instead of opening it in a browser.<br><code>-s, --silent [value]</code> — Do not print any output to the console. | +| <code>vip dev-env</code> | Create and manage VIP Local Development Environments. | — | <code>create</code><br><code>update</code><br><code>start</code><br><code>stop</code><br><code>destroy</code><br><code>info</code><br><code>list</code><br><code>exec</code><br><code>import</code><br><code>shell</code><br><code>logs</code><br><code>sync</code><br><code>purge</code><br><code>envvar</code> | — | +| <code>vip dev-env create</code> | Create a new local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-t, --title [value]</code> — A descriptive value for the WordPress Site Title. Default is "VIP Dev".<br><code>-m, --multisite [value]</code> — Create environment as a multisite. Accepts "y" for a subdomain multisite, "subdirectory" (recommended) for a subdirectory multisite, or "false". Default is "y".<br><code>-w, --wordpress [value]</code> — Manage the version of WordPress. Accepts a string value for major versions (6.x) or "latest". Defaults to the recommended version of WordPress for development.<br><code>-u, --mu-plugins [value]</code> — Manage the source for VIP MU plugins. Accepts "demo" (default) for a read-only image of the staging branch, or a path to a built copy of VIP MU plugins on the local machine.<br><code>-a, --app-code [value]</code> — Manage the source for application code. Accepts "demo" (default) for a read-only image of WordPress VIP skeleton application code, or a path to a VIP formatted application repo on the local machine.<br><code>-p, --phpmyadmin [value]</code> — Enable or disable phpMyAdmin, disabled by default. Accepts "y" (default value) to enable or "n" to disable. When enabled, refer to the value of "PHPMYADMIN URLS" in the information output for a local environment for the URL to access phpMyAdmin.<br><code>-x, --xdebug [value]</code> — Enable or disable XDebug, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>--xdebug_config [value]</code> — Override some default configuration settings for Xdebug. Accepts a string value that is assigned to the XDEBUG_CONFIG environment variable.<br><code>-e, --elasticsearch [value]</code> — Enable or disable Elasticsearch (required by Enterprise Search), disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-r, --media-redirect-domain [value]</code> — Configure media files to be proxied from a VIP Platform environment. Accepts a string value for the primary domain of the VIP Platform environment or "n" to disable the media proxy.<br><code>--php [value]</code> — Manage the version of PHP. Accepts a string value for minor versions: 8.2, 8.3, 8.4, 8.5<br><code>-c, --cron [value]</code> — Enable or disable cron, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-A, --mailpit [value]</code> — Enable or disable Mailpit, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-H, --photon [value]</code> — Enable or disable Photon, disabled by default. Accepts "y" (default value) to enable or "n" to disable. | +| <code>vip dev-env destroy</code> | Remove a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>--soft [value]</code> — Preserve an environment’s configuration files; allows an environment to be regenerated with the start command. | +| <code>vip dev-env envvar</code> | Manage environment variables for a local environment. | — | <code>delete</code><br><code>get</code><br><code>get-all</code><br><code>list</code><br><code>set</code> | — | +| <code>vip dev-env envvar delete</code> | Delete a local environment variable. | <code><name></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local". | +| <code>vip dev-env envvar get</code> | Retrieve the value of a local environment variable. | <code><name></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local". | +| <code>vip dev-env envvar get-all</code> | Retrieve the names and values of all local environment variables. | — | — | <code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.<br><code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local". | +| <code>vip dev-env envvar list</code> | List the names of all local environment variables. | — | — | <code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.<br><code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local". | +| <code>vip dev-env envvar set</code> | Add or update a local environment variable that begins with an uppercase letter and only includes the allowed characters A-Z, 0-9, or \_. | <code><name> [value]</code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-f, --from-file [value]</code> — Read environment variable value from a UTF-8-encoded text file (useful for multiline input). Accepts a relative or absolute path. | +| <code>vip dev-env exec</code> | Run a WP-CLI command against a local environment. | <code>-- <wp-cli command and args...></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-f, --force [value]</code> — Skip validation for a local environment to be in a running state.<br><code>-q, --quiet [value]</code> — Suppress informational messages. | +| <code>vip dev-env import</code> | Import media or database files to a local environment. | — | <code>sql</code><br><code>media</code> | — | +| <code>vip dev-env import media</code> | Import media files to a running local environment. | <code><directory></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local". | +| <code>vip dev-env import sql</code> | Import a SQL file to a running local environment. | <code><file></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-r, --search-replace [value]</code> — Search for a string in the SQL file and replace it with a new string.<br><code>-i, --in-place [value]</code> — Perform a search and replace operation on the local SQL file and save the results.<br><code>--skip-validate [value]</code> — Skip file validation.<br><code>-k, --skip-reindex [value]</code> — Skip Elasticsearch reindex after import.<br><code>-q, --quiet [value]</code> — Skip confirmation and suppress informational messages. | +| <code>vip dev-env info</code> | Retrieve information about a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-a, --all [value]</code> — Retrieve information about all local environments.<br><code>-e, --extended [value]</code> — Deprecated, not used. | +| <code>vip dev-env list</code> | Retrieve information about all local environments. | — | — | — | +| <code>vip dev-env logs</code> | Retrieve logs for a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-f, --follow [value]</code> — Continually output logs as they are generated.<br><code>--service [value]</code> — Restrict to a single service. | +| <code>vip dev-env purge</code> | Remove all local environments. | — | — | <code>-s, --soft [value]</code> — Preserve an environment’s configuration files; allows an environment to be regenerated with the start command.<br><code>-f, --force [value]</code> — Skip confirmation. | +| <code>vip dev-env shell</code> | Create a shell and run commands against a local environment. | <code>-- <shell command and args...></code> | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-r, --root [value]</code> — Create with root privileges.<br><code>--service [value]</code> — Restrict to a single service. | +| <code>vip dev-env start</code> | Start a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>--skip-rebuild [value]</code> — Only start services that are not in a running state.<br><code>-w, --skip-wp-versions-check [value]</code> — Skip the prompt to update WordPress; occurs if the last major release version is not configured.<br><code>--vscode [value]</code> — Generate a Visual Studio Code Workspace file (deprecated, use --editor=vscode instead).<br><code>-e, --editor [value]</code> — Generate a workspace file for the specified editor (supports: vscode, cursor, windsurf, phpstorm). | +| <code>vip dev-env stop</code> | Stop a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-a, --all [value]</code> — Stop all local environments. | +| <code>vip dev-env sync</code> | Sync the database of a VIP Platform environment to a local environment. | — | <code>sql</code> | — | +| <code>vip dev-env sync sql</code> | Sync the database of a VIP Platform environment to a local environment. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-t, --table [value]</code> — The name of a table to include in the partial database sync. Accepts a string value and can be passed more than once with a different value, or add multiple values in a comma-separated list.<br><code>--site-id [value]</code> — The ID of a network site to include in the partial database sync. Accepts an integer value (can be passed more than once with different values), or multiple integer values in a comma-separated list.<br><code>-w, --wpcli-command [value]</code> — Run a custom WP-CLI command that has logic to retrieve specific data for the partial database export.<br><code>-c, --config-file [value]</code> — A local configuration file that specifies the data to include in the partial database sync. Accepts a relative or absolute path to the file.<br><code>-f, --force [value]</code> — Skip validations. | +| <code>vip dev-env update</code> | Update the settings of a local environment. | — | — | <code>-s, --slug [value]</code> — A unique name for a local environment. Default is "vip-local".<br><code>-w, --wordpress [value]</code> — Manage the version of WordPress. Accepts a string value for major versions (6.x) or "latest". Defaults to the recommended version of WordPress for development.<br><code>-u, --mu-plugins [value]</code> — Manage the source for VIP MU plugins. Accepts "demo" (default) for a read-only image of the staging branch, or a path to a built copy of VIP MU plugins on the local machine.<br><code>-a, --app-code [value]</code> — Manage the source for application code. Accepts "demo" (default) for a read-only image of WordPress VIP skeleton application code, or a path to a VIP formatted application repo on the local machine.<br><code>-p, --phpmyadmin [value]</code> — Enable or disable phpMyAdmin, disabled by default. Accepts "y" (default value) to enable or "n" to disable. When enabled, refer to the value of "PHPMYADMIN URLS" in the information output for a local environment for the URL to access phpMyAdmin.<br><code>-x, --xdebug [value]</code> — Enable or disable XDebug, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>--xdebug_config [value]</code> — Override some default configuration settings for Xdebug. Accepts a string value that is assigned to the XDEBUG_CONFIG environment variable.<br><code>-e, --elasticsearch [value]</code> — Enable or disable Elasticsearch (required by Enterprise Search), disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-r, --media-redirect-domain [value]</code> — Configure media files to be proxied from a VIP Platform environment. Accepts a string value for the primary domain of the VIP Platform environment or "n" to disable the media proxy.<br><code>--php [value]</code> — Manage the version of PHP. Accepts a string value for minor versions: 8.2, 8.3, 8.4, 8.5<br><code>-c, --cron [value]</code> — Enable or disable cron, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-A, --mailpit [value]</code> — Enable or disable Mailpit, disabled by default. Accepts "y" (default value) to enable or "n" to disable.<br><code>-H, --photon [value]</code> — Enable or disable Photon, disabled by default. Accepts "y" (default value) to enable or "n" to disable. | +| <code>vip export</code> | Export a copy of data associated with an environment. | — | <code>sql</code> | — | +| <code>vip export sql</code> | Generate a copy of a database backup for an environment and download it as an archived SQL file. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-o, --output [value]</code> — Download the file to a specific local directory path with a custom file name.<br><code>-t, --table [value]</code> — The name of a table to include in the partial database export. Accepts a string value and can be passed more than once with a different value, or add multiple values in a comma-separated list.<br><code>-s, --site-id [value]</code> — The ID of a network site to include in the partial database export. Accepts an integer value and can be passed more than once with a different value, or add multiple values in a comma-separated list.<br><code>-w, --wpcli-command [value]</code> — Run a custom WP-CLI command that has logic to retrieve specific data for the partial database export.<br><code>-c, --config-file [value]</code> — A local configuration file that specifies the data to include in the partial database export. Accepts a relative or absolute path to the file.<br><code>-g, --generate-backup [value]</code> — Generate a fresh database backup and export an archived copy of that backup.<br><code>--skip-download [value]</code> — Skip downloading the file. | +| <code>vip import</code> | Import media or SQL database files to an environment. | — | <code>sql</code><br><code>validate-sql</code><br><code>validate-files</code><br><code>media</code> | — | +| <code>vip import media</code> | Import media files to a production environment from an archived file at a local path or a publicly accessible remote URL. | <code><file|url></code> | <code>status</code><br><code>abort</code> | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --force</code> — Skip confirmation. (default: false)<br><code>--exportFileErrorsToJson [value]</code> — Format the error log in JSON. Default is TXT.<br><code>-s, --saveErrorLog [value]</code> — Skip the confirmation prompt and download an error log for the import automatically.<br><code>-o, --overwriteExistingFiles</code> — Overwrite existing files with the imported files if they have the same path and file name. (default: false)<br><code>-i, --importIntermediateImages</code> — Include intermediate image files in the import. (default: false) | +| <code>vip import media abort</code> | Abort the media import currently in progress. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --force</code> — Skip confirmation. (default: false) | +| <code>vip import media status</code> | Check the status of a currently running media import or retrieve an error log of the most recent media import. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>--exportFileErrorsToJson [value]</code> — Format an error log in JSON. Default is TXT.<br><code>-s, --saveErrorLog [value]</code> — Skip the confirmation prompt and download an error log automatically. | +| <code>vip import sql</code> | Import a SQL database file to an environment. | <code><file|url></code> | <code>status</code> | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-s, --skip-validate [value]</code> — Do not perform file validation prior to import. If the file contains unsupported entries, the import is likely to fail.<br><code>--search-replace [value]</code> — Search for a string in a local or remote SQL database file and replace it with a new string. Separate the values by a comma only; no spaces (e.g. --search-replace="from,to"). Can be passed more than once.<br><code>-i, --in-place [value]</code> — Overwrite a local SQL database file with the results of a search and replace operation prior to import.<br><code>-o, --output [value]</code> — Save the results of a --search-replace operation that is run against a local SQL database file to a copy of that file. Accepts a local file path. Ignored when used with the --in-place option.<br><code>--skip-maintenance-mode [value]</code> — Prevent an unlaunched environment from going into maintenance mode during the import of a local or remote SQL database file. Skipping maintenance mode can cause site instability during import.<br><code>-m, --md5 [value]</code> — Verify the integrity of a remote SQL database file. Accepts an MD5 hash value.<br><code>--header [value]</code> — Pass a header name and value (Formatted as "Name: Value") in a request for a remote SQL database file. Can be passed more than once for multiple headers and values.<br><code>-B, --skip-backup [value]</code> — Skip creating a backup before importing the SQL file. WARNING: This is extremely dangerous and can result in permanent data loss. | +| <code>vip import sql status</code> | Check the status of a SQL database import currently in progress. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type. | +| <code>vip import validate-files</code> | Validate that the directory structure and contents of a local media file directory can be successfully imported. | <code><folder></code> | — | — | +| <code>vip import validate-sql</code> | Validate a local SQL database file prior to import. | <code><file></code> | — | — | +| <code>vip logout</code> | Log out the current authenticated VIP-CLI user. | — | — | — | +| <code>vip logs</code> | Retrieve Runtime Logs from an environment. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-t, --type [value]</code> — Specify the type of Runtime Logs to retrieve. Accepts "batch" (only valid for WordPress environments).<br><code>-l, --limit [value]</code> — The maximum number of entries to return. Accepts an integer value between 1 and 5000 (defaults to 500).<br><code>-f, --follow [value]</code> — Output new entries as they are generated.<br><code>--format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, “json“, and “text”. | +| <code>vip search-replace</code> | Search for a string in a local SQL file and replace it with a new string. | <code><file></code> | — | <code>-s, --search-replace [value]</code> — A comma-separated pair of strings that specify the values to search for and replace (e.g. --search-replace="from,to").<br><code>-i, --in-place [value]</code> — Overwrite the local input file with the results of the search and replace operation.<br><code>-o, --output [value]</code> — The local file path used to save a copy of the results from the search and replace operation. Ignored when used with the --in-place option. | +| <code>vip slowlogs</code> | Retrieve MySQL slow query logs from an environment. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --format [value]</code> — Render output in a particular format. Accepts “table“ (default), “csv“, and “json“.<br><code>-l, --limit [value]</code> — Set the maximum number of log entries. Accepts an integer value between 1 and 500. | +| <code>vip sync</code> | Sync the database from production to a non-production environment. | — | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-f, --force</code> — Skip confirmation. (default: false) | +| <code>vip whoami</code> | Retrieve details about the current authenticated VIP-CLI user. | — | — | — | +| <code>vip wp</code> | Execute a WP-CLI command against an environment. | <code>-- <wp-cli command and args...>; an empty command opens the interactive shell</code> | — | <code>-a, --app [value]</code> — Target an application. Accepts a string value for the application name or an integer for the application ID.<br><code>-e, --env [value]</code> — Target an environment. Accepts a string value for the environment type.<br><code>-y, --yes [value]</code> — Answer yes to the confirmation prompt (only on production environments). | diff --git a/eslint.config.js b/eslint.config.js index a230c44c5..be16ac9e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,18 @@ const { configs } = require( '@automattic/eslint-plugin-wpvip' ); const config = [ { - ignores: [ '*.generated.d.ts', 'dist/**', 'src/graphqlTypes.d.ts', 'codegen.ts' ], + ignores: [ + '*.generated.d.ts', + 'dist/**', + 'src/graphqlTypes.d.ts', + 'codegen.ts', + // Go tree: fixtures for Go tests, not Node CLI source. + 'internal/**', + 'cmd/**', + 'scripts/**', + 'testdata/**', + 'third_party/**', + ], }, ...configs.recommended, ...configs.cli, diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 000000000..dd0b6a2ea --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +UI.user_error!('Please run fastlane via `bundle exec`') unless FastlaneCore::Helper.bundler? + +PROJECT_ROOT_FOLDER = File.dirname(File.expand_path(__dir__)) + +# fastlane match cert storage (S3) — the shared a8c bucket. # ← infra: confirm +CODE_SIGNING_STORAGE_OPTIONS = { + storage_mode: 's3', + s3_bucket: 'a8c-fastlane-match', + s3_region: 'us-east-2' +}.freeze + +# app_store_connect_api_key reads these to build the ASC key. +ASC_API_KEY_ENV_VARS = %w[ + APP_STORE_CONNECT_API_KEY_KEY_ID + APP_STORE_CONNECT_API_KEY_ISSUER_ID + APP_STORE_CONNECT_API_KEY_KEY +].freeze + +require_relative 'lib/env_manager' + +APP_IDENTIFIER = 'com.automattic.vip-cli' # ← infra: reuse team Dev ID cert vs a new match entry +TEAM_ID = 'PZYM8XX95Q' + +before_all do + setup_ci # required for match to work in CI; harmless locally + EnvManager.set_up(env_file_name: 'vip-cli.env') + check_for_toolkit_updates unless is_ci || ENV['FASTLANE_SKIP_TOOLKIT_UPDATE_CHECK'] +end + +# Places the Developer ID Application cert (and — see the infra note — the Installer +# cert) into the keychain so `codesign` / `productsign` can find them. +lane :configure_code_signing do |readonly: true| + EnvManager.require_env_vars!(*ASC_API_KEY_ENV_VARS) + api_key = app_store_connect_api_key + + # Developer ID *Application* cert — signs the binaries (codesign). + sync_code_signing( + app_identifier: APP_IDENTIFIER, platform: 'macos', type: 'developer_id', + api_key: api_key, team_id: TEAM_ID, readonly: readonly, + **CODE_SIGNING_STORAGE_OPTIONS + ) + + # ← infra: ALSO provision the Developer ID *Installer* cert (for productsign on the + # .pkg). The reference doesn't need it. Depending on your match setup this is likely: + # sync_code_signing(app_identifier: APP_IDENTIFIER, platform: 'macos', type: 'developer_id', + # additional_cert_types: ['developer_id_installer'], api_key: api_key, + # team_id: TEAM_ID, readonly: readonly, **CODE_SIGNING_STORAGE_OPTIONS) + # Confirm the exact option/flow, then enable it. +end + +# Notarize a .zip (bare binary) or .pkg. Lane args arrive as strings, so an explicit +# `skip_stapling:false` is the ONLY thing that turns stapling on (bare binaries can't +# be stapled → default true; the .pkg passes false so it gets stapled → offline-verified). +lane :notarize_artifact do |options| + UI.user_error!('notarize_artifact requires path:') unless options[:path] + EnvManager.require_env_vars!(*ASC_API_KEY_ENV_VARS) + + skip_stapling = options[:skip_stapling].to_s != 'false' + notarize( + package: options[:path], + api_key: app_store_connect_api_key, + skip_stapling: skip_stapling, + print_log: true + ) +end diff --git a/fastlane/example.env b/fastlane/example.env new file mode 100644 index 000000000..475ccbb4e --- /dev/null +++ b/fastlane/example.env @@ -0,0 +1,8 @@ +# Copy to ~/.a8c-apps/vip-cli.env for local runs and fill in the values. +# In CI these come from Buildkite secret injection, not this file. +APP_STORE_CONNECT_API_KEY_KEY_ID= +APP_STORE_CONNECT_API_KEY_ISSUER_ID= +APP_STORE_CONNECT_API_KEY_KEY= +MATCH_PASSWORD= +MATCH_S3_ACCESS_KEY= +MATCH_S3_SECRET_ACCESS_KEY= diff --git a/fastlane/lib/env_manager.rb b/fastlane/lib/env_manager.rb new file mode 100644 index 000000000..c48d1bd41 --- /dev/null +++ b/fastlane/lib/env_manager.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# Copied from Automattic/download (fastlane/lib/env_manager.rb). +# ← infra: replace with your canonical release-toolkit version if one exists. + +require 'dotenv' +require 'fastlane' + +# Manages loading of environment variables from a .env and accessing them in a user-friendly way. +class EnvManager + @env_path = nil + @env_example_path = nil + @print_error_lambda = nil + + def self.set_up( + env_file_name:, + env_file_folder: File.join(Dir.home, '.a8c-apps'), + example_env_file_path: 'fastlane/example.env', + print_error_lambda: ->(message) { FastlaneCore::UI.user_error!(message) } + ) + @env_path = File.join(env_file_folder, env_file_name) + @env_example_path = example_env_file_path + @print_error_lambda = print_error_lambda + + Dotenv.load(@env_path) + end + + def self.get_required_env!(key) + unless ENV.key?(key) + message = "Environment variable '#{key}' is not set." + + if running_on_ci? + @print_error_lambda.call(message) + elsif File.exist?(@env_path) + @print_error_lambda.call("#{message} Consider adding it to #{@env_path}.") + else + env_file_dir = File.dirname(@env_path) + env_file_name = File.basename(@env_path) + + @print_error_lambda.call <<~MSG + #{env_file_name} not found in #{env_file_dir} while looking for env var #{key}. + + Please copy #{@env_example_path} to #{@env_path} and fill in the value for #{key}. + + mkdir -p #{env_file_dir} && cp #{@env_example_path} #{@env_path} + MSG + end + end + + value = ENV.fetch(key) + FastlaneCore::UI.user_error!("Env var for key #{key} is set but empty. Please set a value for #{key}.") if value.to_s.empty? + value + end + + def self.require_env_vars!(*keys) + keys.each { |key| get_required_env!(key) } + end + + def self.running_on_ci? + ENV['CI'] == 'true' + end +end diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..f10b4fa99 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/Automattic/vip + +go 1.27 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/Khan/genqlient v0.8.1 + github.com/coder/websocket v1.8.15 + github.com/creack/pty v1.1.17 + github.com/fatih/color v1.19.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/spf13/cobra v1.10.2 + github.com/vektah/gqlparser/v2 v2.5.19 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.55.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..fa133eccf --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y= +github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.8.0 h1:fQOTjATVQl5RhssBro6ZuHANFybCkmJ7FjYPo4b7sEY= +github.com/jedib0t/go-pretty/v6 v6.8.0/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.19 h1:bhCPCX1D4WWzCDvkPl4+TP1N8/kLrWnp43egplt7iSg= +github.com/vektah/gqlparser/v2 v2.5.19/go.mod h1:y7kvl5bBlDeuWIvLtA9849ncyvx6/lj06RsMrEjVy3U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/appctx/app_resolver.go b/internal/appctx/app_resolver.go new file mode 100644 index 000000000..6f5d7a072 --- /dev/null +++ b/internal/appctx/app_resolver.go @@ -0,0 +1,202 @@ +package appctx + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/gql" +) + +// AppContextConfig wires the WithAppContext middleware to a GraphQL client. +type AppContextConfig struct { + Client graphql.Client +} + +// WithAppContext returns a middleware that resolves the --app flag (or the +// @app alias propagated into it by envalias.Rewrite) to an App and stashes +// it in cmd.Context() via WithAppEnv. +// +// Behavior: +// - --app numeric -> ResolveAppByID +// - --app non-numeric -> ResolveAppByName (first match) +// - --app empty, NI -> error +// - --app empty, interactive -> prompt for name, then resolve +// - no match -> error mentioning the lookup key +// +// The full app.environments list is stashed via AppEnv.SetAvailableEnvs so +// WithEnvContext (Task 9) can narrow without another network roundtrip. +func WithAppContext(cfg AppContextConfig) Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + appFlag := lookupFlag(cmd, "app") + if appFlag == "" { + prompted, err := Input(cmd, "App name or ID:", "") + if err != nil { + return fmt.Errorf("--app is required: %w", err) + } + appFlag = strings.TrimSpace(prompted) + if appFlag == "" { + return fmt.Errorf("--app is required") + } + } + + envFlag := lookupFlag(cmd, "env") + + app, envs, err := resolveApp(cmd, cfg.Client, appFlag, envFlag) + if err != nil { + return err + } + + ae := FromContext(cmd.Context()) + if ae == nil { + ae = &AppEnv{} + } + ae.App = app + ae.SetAvailableEnvs(envs) + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + } + } +} + +// envGetter unifies the differently-named env structs that ResolveAppByID +// and ResolveAppByName produce. genqlient emits a GetXxx() method per field +// on every node type, so both `*ResolveAppByIDAppEnvironmentsAppEnvironment` +// and `*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment` +// satisfy this interface — no reflection needed. +type envGetter interface { + GetId() *int64 + GetAppId() *int64 + GetName() *string + GetType() *string + GetUniqueLabel() *string + GetDefaultDomain() *string + GetIsMultisite() *bool +} + +func resolveApp(cmd *cobra.Command, client graphql.Client, appKey, envKey string) (App, []Env, error) { + if client == nil { + return App{}, nil, fmt.Errorf("appctx: GraphQL client not configured") + } + // envKey is intentionally NOT passed to the GraphQL query: the + // environments field has no useful filter (server-side filter would only + // match env.name, but Node's getEnvIdentifier resolves on env.type for the + // main env). Fetch all envs and filter client-side in WithEnvContext. + _ = envKey + ctx := cmd.Context() + + if id, err := strconv.ParseInt(appKey, 10, 64); err == nil { + resp, qerr := gql.ResolveAppByID(ctx, client, id) + if qerr != nil { + return App{}, nil, fmt.Errorf("resolve app id=%d: %w", id, qerr) + } + if resp == nil || resp.App == nil || resp.App.Id == nil { + return App{}, nil, fmt.Errorf("no app matching id=%d found", id) + } + envGetters := make([]envGetter, 0, len(resp.App.Environments)) + for _, e := range resp.App.Environments { + if e != nil { + envGetters = append(envGetters, e) + } + } + return buildApp(resp.App.Id, resp.App.Name, resp.App.Type, resp.App.TypeId), envsFromGetters(envGetters), nil + } + + resp, err := gql.ResolveAppByName(ctx, client, appKey) + if err != nil { + return App{}, nil, fmt.Errorf("resolve app name=%q: %w", appKey, err) + } + if resp == nil || resp.Apps == nil || len(resp.Apps.Edges) == 0 || resp.Apps.Edges[0] == nil { + return App{}, nil, fmt.Errorf("no app matching name=%q found", appKey) + } + edge := resp.Apps.Edges[0] + envGetters := make([]envGetter, 0, len(edge.Environments)) + for _, e := range edge.Environments { + if e != nil { + envGetters = append(envGetters, e) + } + } + return buildApp(edge.Id, edge.Name, edge.Type, edge.TypeId), envsFromGetters(envGetters), nil +} + +func buildApp(id *int64, name *string, appType *string, typeId *int64) App { + var a App + if id != nil { + a.ID = *id + } + if name != nil { + a.Name = *name + } + if appType != nil { + a.Type = *appType + } + if typeId != nil { + a.TypeId = *typeId + } + return a +} + +func envsFromGetters(getters []envGetter) []Env { + if len(getters) == 0 { + return nil + } + out := make([]Env, 0, len(getters)) + for _, g := range getters { + e := Env{} + if id := g.GetId(); id != nil { + e.ID = *id + } + if appID := g.GetAppId(); appID != nil { + e.AppId = *appID + } + if name := g.GetName(); name != nil { + e.Name = *name + } + if typ := g.GetType(); typ != nil { + e.Type = *typ + } + if ul := g.GetUniqueLabel(); ul != nil { + e.UniqueLabel = *ul + } + if d := g.GetDefaultDomain(); d != nil { + e.DefaultDomain = *d + } + if multisite := g.GetIsMultisite(); multisite != nil { + e.IsMultisite = *multisite + } + if e.ID == 0 && e.Name == "" && e.Type == "" { + continue + } + out = append(out, e) + } + if len(out) == 0 { + return nil + } + return out +} + +func ptrIfNonEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +// lookupFlag returns the string value of the named flag, walking both local +// and persistent flag tables on cmd (and any ancestor that propagated a +// persistent flag through pflag's lookup chain). Returns "" if the flag is +// not defined. Mirrors how interactive.go reads --non-interactive: directly +// off the *pflag.Flag so we don't depend on Cobra's lazy merge having run. +func lookupFlag(cmd *cobra.Command, name string) string { + if cmd == nil { + return "" + } + if f := cmd.Flag(name); f != nil { + return f.Value.String() + } + return "" +} diff --git a/internal/appctx/app_resolver_test.go b/internal/appctx/app_resolver_test.go new file mode 100644 index 000000000..773d786a5 --- /dev/null +++ b/internal/appctx/app_resolver_test.go @@ -0,0 +1,187 @@ +package appctx + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" +) + +func gqlClientForServer(srv *httptest.Server) graphql.Client { + return graphql.NewClient(srv.URL+"/graphql", srv.Client()) +} + +func makeAppCmd(app, env string, nonInteractive bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().String("app", app, "") + cmd.PersistentFlags().String("env", env, "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + if nonInteractive { + _ = cmd.PersistentFlags().Set("non-interactive", "true") + } + cmd.SetContext(context.Background()) + return cmd +} + +func TestWithAppContextResolvesByName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example","isMultisite":true}]}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("myapp", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.App.ID != 42 || ae.App.Name != "myapp" { + t.Errorf("AppEnv = %+v", ae) + } + envs := ae.AvailableEnvs() + if len(envs) != 1 || envs[0].ID != 7 || envs[0].Type != "develop" || envs[0].DefaultDomain != "d.example" { + t.Errorf("AvailableEnvs = %+v", envs) + } + if !envs[0].IsMultisite { + t.Errorf("AvailableEnvs[0].IsMultisite = false, want true") + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } +} + +func TestWithAppContextResolvesByID(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","isMultisite":true}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("42", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.App.ID != 42 || ae.App.Name != "myapp" { + t.Errorf("AppEnv = %+v", ae) + } + envs := ae.AvailableEnvs() + if len(envs) != 1 || !envs[0].IsMultisite { + t.Errorf("AvailableEnvs = %+v, want one multisite env", envs) + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } + if !strings.Contains(gotBody, "ResolveAppByID") { + t.Errorf("expected ResolveAppByID in request body; got %s", gotBody) + } +} + +func TestWithAppContextMissingAppNonInteractive(t *testing.T) { + cmd := makeAppCmd("", "", true) + mw := WithAppContext(AppContextConfig{Client: nil}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner handler must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "--app") { + t.Errorf("err = %v, want missing --app error", err) + } +} + +func TestWithAppContextNotFoundByName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("ghost", "", true) + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when app not found") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Errorf("err = %v, want not-found error mentioning the key", err) + } +} + +func TestWithAppContextNotFoundByID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":null}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("999", "", true) + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when app not found") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "999") { + t.Errorf("err = %v, want not-found error mentioning the id", err) + } +} + +func TestWithAppContextNilClientErrors(t *testing.T) { + cmd := makeAppCmd("myapp", "", true) + mw := WithAppContext(AppContextConfig{Client: nil}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when client is nil") + return nil + }) + err := run(cmd, nil) + if err == nil { + t.Fatal("expected an error when Client is nil") + } +} + +func TestWithAppContextPopulatesTypeId(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"x","typeId":3,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("x", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil { + t.Fatal("AppEnv is nil") + } + if ae.App.TypeId != 3 { + t.Errorf("App.TypeId = %d, want 3", ae.App.TypeId) + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } +} diff --git a/internal/appctx/confirm.go b/internal/appctx/confirm.go new file mode 100644 index 000000000..43133954e --- /dev/null +++ b/internal/appctx/confirm.go @@ -0,0 +1,180 @@ +package appctx + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// confirmPrompt is the seam the confirm middlewares call instead of Confirm +// directly, so tests can observe the exact prompt text (and answer it) +// without a TTY. Production value is Confirm. +var confirmPrompt = Confirm + +// ConfirmPayload contributes module-specific rows to the confirmation info +// table and may rewrite the confirm message. It is the port of the +// `switch (_opts.module)` block in src/lib/cli/command.js:858-983. +// +// It runs BEFORE anything is printed, so returning an error aborts the +// command with nothing on screen — that is how Node's sync module refuses a +// sync the server would reject (command.js:914-920 calls exit.withError from +// inside the switch, before confirm() is ever reached). +// +// message is the current confirm message; the returned string replaces it +// (import-media rewrites "the URL" -> "the path" for local archives). +type ConfirmPayload func(cmd *cobra.Command, args []string, message string) ([]output.Tuple, string, error) + +// ensureSkipConfirmationFlag registers --skip-confirmation on cmd's persistent +// flags. It is idempotent — if the flag already exists it is a no-op. +func ensureSkipConfirmationFlag(cmd *cobra.Command) { + if cmd.Flag("skip-confirmation") != nil { + return + } + // Register on PersistentFlags so subcommands inherit it, and also merge it + // into the local FlagSet so cmd.Flags().Set/GetBool work in tests and when + // Cobra hasn't yet performed its lazy persistent-flag merge. + cmd.PersistentFlags().Bool("skip-confirmation", false, "Skip confirmation prompts.") + cmd.Flags().AddFlagSet(cmd.PersistentFlags()) +} + +// WithSkipConfirmationFlag registers --skip-confirmation on cmd at apply time +// (so Cobra parses it before RunE) and returns a pass-through Middleware. +// Calling it on a cmd that already has the flag is a no-op. +func WithSkipConfirmationFlag(cmd *cobra.Command) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + return next(c, args) + } + } +} + +// WithConfirm gates execution on a production-only yes/no prompt with the +// given static message. Non-production envs proceed without prompting. +// --skip-confirmation bypasses unconditionally. Decline (or non-interactive +// context) prints "Command cancelled" to stdout and returns nil (exit 0). +func WithConfirm(cmd *cobra.Command, message string) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + // --skip-confirmation bypasses unconditionally. + if skip, _ := c.Flags().GetBool("skip-confirmation"); skip { + return next(c, args) + } + + // Production gate: only prompt on production envs. + ae := FromContext(c.Context()) + if ae == nil || ae.Env.Type != "production" { + return next(c, args) + } + + // Prompt the user. + confirmed, err := confirmPrompt(c, message, false) + if err == ErrNonInteractive || (!confirmed && err == nil) { + fmt.Fprintln(c.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + return next(c, args) + } + } +} + +// WithRequireConfirm gates execution on an unconditional yes/no prompt +// (no production gating). --skip-confirmation bypasses. Decline (or +// non-interactive context) prints "Command cancelled" to stdout and returns +// nil (exit 0). +// +// Node parity (src/lib/cli/command.js:840-994 + src/lib/cli/prompt.ts:14): +// an info table listing the target App and Environment — plus any +// module-specific rows contributed by `payload` — is console.logged to +// STDOUT immediately above the yes/no question. Without it users were asked +// to authorize destroying a database without being told which one. +// +// The whole block, table included, lives behind `! options.force` in Node, +// so --skip-confirmation / --force renders nothing at all and never runs the +// payload. Do not "fix" that asymmetry: a table under --skip-confirmation +// would be output Node never produces. +func WithRequireConfirm(cmd *cobra.Command, message string, payload ...ConfirmPayload) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + // --skip-confirmation bypasses unconditionally. + if skip, _ := c.Flags().GetBool("skip-confirmation"); skip { + return next(c, args) + } + + info := appEnvInfoRows(c) + for _, p := range payload { + if p == nil { + continue + } + rows, rewritten, err := p(c, args, message) + if err != nil { + return err + } + info = append(info, rows...) + message = rewritten + } + fmt.Fprintln(c.OutOrStdout(), output.KeyValue(info)) + + // Prompt the user. + confirmed, err := confirmPrompt(c, message, false) + if err == ErrNonInteractive || (!confirmed && err == nil) { + fmt.Fprintln(c.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + return next(c, args) + } + } +} + +// appEnvInfoRows builds the two rows every requireConfirm command shows +// (command.js:844-851). Node guards each on `options.app` / `options.env` +// being set by the appContext/envContext middleware; the Go equivalent is a +// non-zero resolved ID on the AppEnv carrier. +func appEnvInfoRows(c *cobra.Command) []output.Tuple { + ae := FromContext(c.Context()) + if ae == nil { + return nil + } + var rows []output.Tuple + if ae.App.ID != 0 { + rows = append(rows, output.Tuple{ + Key: "App", + Value: fmt.Sprintf("%s (id: %d)", ae.App.Name, ae.App.ID), + }) + } + if ae.Env.ID != 0 { + rows = append(rows, output.Tuple{ + Key: "Environment", + Value: fmt.Sprintf("%s (id: %d)", getEnvIdentifier(ae.Env), ae.Env.ID), + }) + } + return rows +} + +// Secret prompts for a masked-input secret value. Returns ErrNonInteractive +// in non-interactive contexts. +// +// Intentional Node deviation: Node uses a plain Input prompt; Go masks input +// so envvar values do not appear in terminal scrollback. Parity scenarios for +// envvar set use --from-file + --skip-confirmation to bypass. +func Secret(cmd *cobra.Command, message string) (string, error) { + if !IsInteractive(cmd) { + return "", ErrNonInteractive + } + var out string + if err := survey.AskOne(&survey.Password{Message: message}, &out); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/appctx/confirm_payload_test.go b/internal/appctx/confirm_payload_test.go new file mode 100644 index 000000000..bbfa71e63 --- /dev/null +++ b/internal/appctx/confirm_payload_test.go @@ -0,0 +1,201 @@ +package appctx + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// stubConfirm replaces the survey-backed prompt so tests can observe the +// message that would have been shown and choose the answer. Returns a +// restore func. +func stubConfirm(t *testing.T, answer bool, seen *string) { + t.Helper() + prev := confirmPrompt + confirmPrompt = func(_ *cobra.Command, message string, _ bool) (bool, error) { + if seen != nil { + *seen = message + } + return answer, nil + } + t.Cleanup(func() { confirmPrompt = prev }) +} + +func requireConfirmCmd(t *testing.T, ae *AppEnv) (*cobra.Command, *bytes.Buffer) { + t.Helper() + t.Setenv("NO_COLOR", "1") + cmd := &cobra.Command{Use: "x"} + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if ae != nil { + cmd.SetContext(WithAppEnv(context.Background(), ae)) + } else { + cmd.SetContext(context.Background()) + } + return cmd, &stdout +} + +// Node's requireConfirm builds an info table and confirm() console.logs it +// ABOVE the yes/no prompt (command.js:840-851 + prompt.ts:14). vip-next +// printed only the message, so users authorized destructive actions without +// being told which app/environment they targeted. +func TestWithRequireConfirmRendersAppAndEnvironmentRows(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + stubConfirm(t, true, nil) + + mw := WithRequireConfirm(cmd, "Are you sure you want to sync from production?") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Fatal("handler must run after a yes") + } + + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "===================================\n" + if stdout.String() != want { + t.Errorf("info table mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// getEnvIdentifier disambiguates sibling envs of the same type, so a +// non-main env renders as "type.name". +func TestWithRequireConfirmEnvironmentRowUsesEnvIdentifier(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 9, AppId: 7, Type: "develop", Name: "second"}, + }) + stubConfirm(t, true, nil) + + mw := WithRequireConfirm(cmd, "Are you sure?") + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(stdout.String(), "+ Environment: develop.second (id: 9)\n") { + t.Errorf("want Environment row using getEnvIdentifier; got %q", stdout.String()) + } +} + +// Node's whole requireConfirm block — including the console.log of the +// info table — is inside `if (_opts.requireConfirm && ! options.force)`. +// --force / --skip-confirmation therefore prints NOTHING. +func TestWithRequireConfirmSkipFlagPrintsNoInfoTable(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + mw := WithRequireConfirm(cmd, "Are you sure?") + _ = cmd.Flags().Set("skip-confirmation", "true") + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if stdout.Len() != 0 { + t.Errorf("--skip-confirmation must print nothing; got %q", stdout.String()) + } +} + +// The module rows (command.js:858-983) are appended AFTER App/Environment. +func TestWithRequireConfirmAppendsModulePayloadRows(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 1, Name: "app"}, + Env: Env{ID: 2, AppId: 2, Type: "production", Name: "production"}, + }) + stubConfirm(t, true, nil) + + payload := func(*cobra.Command, []string, string) ([]output.Tuple, string, error) { + return []output.Tuple{{Key: "From backup", Value: "Mon, 21 Jul 2025 10:11:12 GMT"}}, "Are you sure?", nil + } + mw := WithRequireConfirm(cmd, "Are you sure?", payload) + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + want := "===================================\n" + + "+ App: app (id: 1)\n" + + "+ Environment: production (id: 2)\n" + + "+ From backup: Mon, 21 Jul 2025 10:11:12 GMT\n" + + "===================================\n" + if stdout.String() != want { + t.Errorf("info table mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// The sync module's canSync guard exits BEFORE the prompt and before the +// destructive mutation. A payload error must abort the whole chain and must +// not render a table or invoke the handler. +func TestWithRequireConfirmPayloadErrorAbortsBeforeHandler(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{App: App{ID: 1, Name: "app"}}) + stubConfirm(t, true, nil) + + boom := errors.New("Could not sync to this environment: nope") + payload := func(*cobra.Command, []string, string) ([]output.Tuple, string, error) { + return nil, "", boom + } + called := false + mw := WithRequireConfirm(cmd, "Are you sure?", payload) + err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + if called { + t.Error("handler must not run when the payload refuses") + } + if stdout.Len() != 0 { + t.Errorf("no info table should be printed on refusal; got %q", stdout.String()) + } +} + +// import-media rewrites "the URL" -> "the path" for local archives +// (command.js:944-947), so a payload must be able to replace the message. +func TestWithRequireConfirmPayloadCanRewriteMessage(t *testing.T) { + cmd, _ := requireConfirmCmd(t, &AppEnv{App: App{ID: 1, Name: "app"}}) + var seen string + stubConfirm(t, true, &seen) + + payload := func(_ *cobra.Command, _ []string, message string) ([]output.Tuple, string, error) { + return nil, strings.ReplaceAll(message, "the URL", "the path"), nil + } + mw := WithRequireConfirm(cmd, "Are you sure you want to import the contents of the URL?", payload) + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if seen != "Are you sure you want to import the contents of the path?" { + t.Errorf("prompt message = %q", seen) + } +} + +// A declined prompt still leaves the table on screen (Node prints it first) +// and cancels with exit 0. +func TestWithRequireConfirmDeclineStillRendersTable(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + stubConfirm(t, false, nil) + + called := false + mw := WithRequireConfirm(cmd, "Are you sure?") + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("handler must not run after a no") + } + if !strings.Contains(stdout.String(), "+ App: my-app (id: 42)") { + t.Errorf("table must be printed before the prompt; got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("want 'Command cancelled'; got %q", stdout.String()) + } +} diff --git a/internal/appctx/confirm_test.go b/internal/appctx/confirm_test.go new file mode 100644 index 000000000..450f08642 --- /dev/null +++ b/internal/appctx/confirm_test.go @@ -0,0 +1,120 @@ +package appctx + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithSkipConfirmationFlagRegistersAtApplyTime(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + _ = WithSkipConfirmationFlag(cmd) + if cmd.Flag("skip-confirmation") == nil { + t.Fatal("--skip-confirmation must be registered at apply time so Cobra parses it before RunE") + } + // Idempotent: applying again on the same cmd must not panic / double-register. + _ = WithSkipConfirmationFlag(cmd) +} + +func TestWithConfirmSkipsPromptWhenSkipFlagSet(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "production"}, + })) + _ = cmd.Flags().Set("skip-confirmation", "true") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("handler must run when --skip-confirmation is set (no prompt)") + } +} + +func TestWithConfirmSkipsNonProduction(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "develop"}, + })) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("non-production envs must skip the confirm prompt") + } +} + +func TestWithConfirmProdNonInteractiveCancels(t *testing.T) { + // VIP_NON_INTERACTIVE=1 makes IsInteractive return false; Confirm + // returns ErrNonInteractive; the middleware treats that as decline, + // prints "Command cancelled" to stdout, returns nil (exit 0 — user-cancel != error). + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "production"}, + })) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("non-interactive prod confirm should cancel without invoking handler") + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("stdout must contain 'Command cancelled'; got %q", stdout.String()) + } +} + +func TestWithRequireConfirmSkipsPromptWhenSkipFlagSet(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithRequireConfirm(cmd, "Are you sure you want to do the thing?") + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("skip-confirmation", "true") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("handler must run when --skip-confirmation is set") + } +} + +func TestWithRequireConfirmNonInteractiveCancels(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + mw := WithRequireConfirm(cmd, "Are you sure?") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("non-interactive WithRequireConfirm should cancel without invoking handler") + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("stdout must contain 'Command cancelled'; got %q", stdout.String()) + } +} + +func TestSecretNonInteractiveReturnsErr(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + _, err := Secret(cmd, "Enter the value:") + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } +} diff --git a/internal/appctx/context.go b/internal/appctx/context.go new file mode 100644 index 000000000..8b294362e --- /dev/null +++ b/internal/appctx/context.go @@ -0,0 +1,94 @@ +// Package appctx — context helpers for command-middleware. WithAppContext +// (Task 8) and WithEnvContext (Task 9) stash resolved metadata here so +// handlers can fetch it via FromContext(cmd.Context()). +package appctx + +import "context" + +// App is the resolved app metadata. Mirrors src/lib/api/app.ts return shape. +// TypeId is the platform site-type identifier (e.g. 1 = Node.js). It defaults +// to 0 when the server omits the field (legacy fixtures, older API versions), +// which is interpreted as "unknown / not Node.js" — preserving Node parity for +// callers that branch on TypeId == 1. +type App struct { + ID int64 + Name string + TypeId int64 + // Type is the human-readable application type (e.g. "WordPress", + // "node"). Media-import commands gate on it (media-file-import.ts:18). + Type string +} + +// Env is a resolved environment. DefaultDomain is a String scalar in the +// schema (not an object), so it's a plain Go string here — matches the +// shape ResolveAppByName / ResolveAppByID produce. +// +// AppId mirrors the schema's AppEnvironment.appId. It identifies the "main" +// env (Node parity: `env.appId === env.id` marks the env that owns the +// canonical app slug; see getEnvIdentifier in env_resolver.go). +type Env struct { + ID int64 + AppId int64 + Name string + Type string // "production" | "develop" | "staging" | ... + DefaultDomain string + // UniqueLabel is the env's dashboard slug (e.g. "develop"); used in + // dashboard URLs by export sql and app deploy. + UniqueLabel string + IsMultisite bool +} + +// AppEnv pairs the resolved App with its target Env. Either field may be +// zero-valued: WithAppContext sets only App + envs; WithEnvContext narrows +// to a single Env from the envs list. +type AppEnv struct { + App App + Env Env + envs []Env // populated by WithAppContext; consumed by WithEnvContext +} + +// AvailableEnvs returns the candidate envs from the resolved App. Used by +// WithEnvContext (Task 9) for auto-select / prompt / lookup. Returns a copy +// so callers can't mutate the carrier's slice. +func (a *AppEnv) AvailableEnvs() []Env { + if a == nil || len(a.envs) == 0 { + return nil + } + out := make([]Env, len(a.envs)) + copy(out, a.envs) + return out +} + +// SetAvailableEnvs replaces the candidate-envs list. Package-internal use +// by app_resolver.go (Task 8). Stores a copy to avoid aliasing the caller's +// slice into the carrier. +func (a *AppEnv) SetAvailableEnvs(envs []Env) { + if a == nil { + return + } + if len(envs) == 0 { + a.envs = nil + return + } + a.envs = make([]Env, len(envs)) + copy(a.envs, envs) +} + +type appEnvKey struct{} + +// WithAppEnv returns a new context carrying ae. +func WithAppEnv(ctx context.Context, ae *AppEnv) context.Context { + return context.WithValue(ctx, appEnvKey{}, ae) +} + +// FromContext extracts the AppEnv set by WithAppEnv, or nil if absent. +// Returns nil if ctx is nil — cobra.Command.Context() can be nil when no +// SetContext / ExecuteContext has run, so middleware that probes for AppEnv +// must not panic on that path. +func FromContext(ctx context.Context) *AppEnv { + if ctx == nil { + return nil + } + v, _ := ctx.Value(appEnvKey{}).(*AppEnv) + return v +} diff --git a/internal/appctx/context_test.go b/internal/appctx/context_test.go new file mode 100644 index 000000000..3e10123c8 --- /dev/null +++ b/internal/appctx/context_test.go @@ -0,0 +1,56 @@ +package appctx + +import ( + "context" + "testing" +) + +func TestAppEnvRoundTrip(t *testing.T) { + ctx := context.Background() + ae := &AppEnv{ + App: App{ID: 42, Name: "myapp"}, + Env: Env{ID: 7, Name: "develop", Type: "develop"}, + } + ctx = WithAppEnv(ctx, ae) + got := FromContext(ctx) + if got == nil { + t.Fatal("FromContext returned nil") + } + if got.App.ID != 42 || got.App.Name != "myapp" { + t.Errorf("App = %+v", got.App) + } + if got.Env.ID != 7 || got.Env.Type != "develop" { + t.Errorf("Env = %+v", got.Env) + } +} + +func TestFromContextEmpty(t *testing.T) { + if got := FromContext(context.Background()); got != nil { + t.Errorf("FromContext(empty) = %+v, want nil", got) + } +} + +func TestFromContextIgnoresOtherKeys(t *testing.T) { + type otherKey struct{} + ctx := context.WithValue(context.Background(), otherKey{}, "intruder") + if got := FromContext(ctx); got != nil { + t.Errorf("FromContext on unrelated key = %+v, want nil", got) + } +} + +// AvailableEnvs returns the env list populated by WithAppContext for +// WithEnvContext to consume. This test pins the contract. +func TestAvailableEnvsRoundTrip(t *testing.T) { + ae := &AppEnv{App: App{ID: 1, Name: "a"}} + ae.SetAvailableEnvs([]Env{ + {ID: 1, Name: "production", Type: "production"}, + {ID: 2, Name: "develop", Type: "develop"}, + }) + got := ae.AvailableEnvs() + if len(got) != 2 { + t.Fatalf("AvailableEnvs len = %d, want 2", len(got)) + } + if got[0].Type != "production" || got[1].Type != "develop" { + t.Errorf("envs = %+v", got) + } +} diff --git a/internal/appctx/env_resolver.go b/internal/appctx/env_resolver.go new file mode 100644 index 000000000..e9a104c51 --- /dev/null +++ b/internal/appctx/env_resolver.go @@ -0,0 +1,110 @@ +package appctx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// WithEnvContext expects WithAppContext to have run earlier in the chain. +// It picks the target Env from AppEnv.AvailableEnvs() using --env, or auto- +// selects when the app has exactly one env, or prompts (interactive), +// or errors (non-interactive with multiple envs). +func WithEnvContext() Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil { + return fmt.Errorf("WithEnvContext requires WithAppContext earlier in the chain") + } + envs := ae.AvailableEnvs() + + envFlag := "" + if f := cmd.Flag("env"); f != nil { + envFlag = f.Value.String() + } + + if envFlag == "" { + switch len(envs) { + case 0: + return fmt.Errorf("app %q has no environments", ae.App.Name) + case 1: + ae.Env = envs[0] + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + default: + ids := envIdentifiers(envs) + if !IsInteractive(cmd) { + return fmt.Errorf("--env is required (one of %s)", strings.Join(ids, ", ")) + } + picked, err := Select(cmd, + fmt.Sprintf("Choose an environment for %s:", ae.App.Name), ids) + if err != nil { + return fmt.Errorf("--env is required (one of %s): %w", + strings.Join(ids, ", "), err) + } + envFlag = picked + } + } + + needle := strings.ToLower(envFlag) + for _, e := range envs { + if strings.ToLower(getEnvIdentifier(e)) == needle { + ae.Env = e + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + } + } + return fmt.Errorf("environment %q not found on app %q; available: %s", + envFlag, ae.App.Name, strings.Join(envIdentifiers(envs), ", ")) + } + } +} + +// WithChildEnvContext is WithEnvContext + rejection of production envs. +// Mirrors Node's _opts.childEnvContext; used by destructive commands that +// must never run on production. +func WithChildEnvContext() Middleware { + inner := WithEnvContext() + return func(next RunFunc) RunFunc { + return inner(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae != nil && ae.Env.Type == "production" { + return fmt.Errorf("this command cannot run on production environments") + } + return next(cmd, args) + }) + } +} + +func envNames(envs []Env) []string { + out := make([]string, 0, len(envs)) + for _, e := range envs { + out = append(out, e.Name) + } + return out +} + +// getEnvIdentifier ports Node's src/lib/cli/command.js helper of the same +// name. For the canonical "main" env on an app (where env.appId == env.id) +// it returns env.type ("production", "develop", ...). For sibling envs of +// the same type (the disambiguating case) it returns "type.name". +// +// This is what users type as the env half of @app.env aliases — matching +// must be case-insensitive against this identifier, NOT env.name alone. +func getEnvIdentifier(e Env) string { + identifier := e.Type + if e.Name != "" && e.Name != e.Type && e.AppId != e.ID { + identifier = e.Type + "." + e.Name + } + return identifier +} + +func envIdentifiers(envs []Env) []string { + out := make([]string, 0, len(envs)) + for _, e := range envs { + out = append(out, getEnvIdentifier(e)) + } + return out +} diff --git a/internal/appctx/env_resolver_test.go b/internal/appctx/env_resolver_test.go new file mode 100644 index 000000000..8299b3e80 --- /dev/null +++ b/internal/appctx/env_resolver_test.go @@ -0,0 +1,215 @@ +package appctx + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func makeEnvCmd(app, env string, nonInteractive bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().String("app", app, "") + cmd.PersistentFlags().String("env", env, "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + if nonInteractive { + _ = cmd.PersistentFlags().Set("non-interactive", "true") + } + cmd.SetContext(context.Background()) + return cmd +} + +// TestWithEnvContextResolvesByTypeMainEnv reproduces the @app.production +// alias case: env.name = app slug, env.type = "production", env.appId = +// env.id (main env). Node's getEnvIdentifier returns env.type here, so +// `--env=production` must match. +func TestWithEnvContextResolvesByTypeMainEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":3453,"name":"cantina-trunk-staging","environments":[{"id":3453,"appId":3453,"name":"cantina-trunk-staging","type":"production","defaultDomain":"www.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("cantina-trunk-staging", "production", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.Type != "production" || ae.Env.ID != 3453 { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextResolves(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"},{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "develop", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.ID != 7 || ae.Env.Name != "develop" || ae.Env.Type != "develop" { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextEnvNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "ghostenv", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "ghostenv") { + t.Errorf("err = %v, want not-found", err) + } + if !strings.Contains(err.Error(), "develop") { + t.Errorf("err should list available envs; got %v", err) + } +} + +func TestWithEnvContextAutoSelectsSingleEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.ID != 7 { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextMultipleNonInteractiveRequiresFlag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"},{"id":1,"name":"production","type":"production"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called without --env") + return nil + }) + err := run(cmd, nil) + if err == nil { + t.Fatal("expected error when --env required and not set in non-interactive mode") + } + if !strings.Contains(err.Error(), "develop") || !strings.Contains(err.Error(), "production") { + t.Errorf("err should list available envs; got %v", err) + } +} + +func TestWithEnvContextNoEnvs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "myapp") { + t.Errorf("err = %v, want error mentioning the app has no envs", err) + } +} + +func TestWithEnvContextMissingAppCtxErrors(t *testing.T) { + cmd := makeEnvCmd("myapp", "develop", true) + mw := WithEnvContext() + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called when AppContext missing") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "WithAppContext") { + t.Errorf("err = %v, want a clear 'WithAppContext required earlier in chain' error", err) + } +} + +func TestWithChildEnvContextRejectsProduction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":1,"name":"production","type":"production"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "production", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithChildEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called for production") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "production") { + t.Errorf("err = %v, want production rejection", err) + } +} + +func TestWithChildEnvContextAllowsDevelop(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "develop", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithChildEnvContext(), + ) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner not called on develop env") + } +} diff --git a/internal/appctx/format.go b/internal/appctx/format.go new file mode 100644 index 000000000..284285f6d --- /dev/null +++ b/internal/appctx/format.go @@ -0,0 +1,72 @@ +package appctx + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// RenderableRunFunc is the handler shape WithFormat wraps. Handlers return +// data (one of output.HeaderData | output.OrderedRows | output.Rows | nil) +// plus an error. WithFormat dispatches the data to output.Render with the +// validated format. +type RenderableRunFunc func(cmd *cobra.Command, args []string) (any, error) + +type formatKey struct{} + +// WithFormat adds the --format flag (default defaultFormat), validates against +// `allowed`, stashes the resolved format in cmd.Context() (read via +// FormatFromContext), and wraps the handler return through output.Render. +// +// cmd is the cobra.Command the flag should be registered on. --format is +// registered immediately at apply time (not lazily inside RunE) so cobra can +// parse it before the command runs. +// +// Use via Builder.WithRenderableRun so the (any, error) shape is preserved. +func WithFormat(cmd *cobra.Command, defaultFormat string, allowed ...string) func(RenderableRunFunc) RenderableRunFunc { + allowedSet := make(map[string]bool, len(allowed)) + for _, a := range allowed { + allowedSet[a] = true + } + // Register the flag immediately at apply time so cobra can parse it before + // RunE is invoked. Previously this was done lazily inside the closure, + // which caused "unknown flag: --format" errors at parse time. + ensureFormatFlag(cmd, defaultFormat) + return func(next RenderableRunFunc) RenderableRunFunc { + return func(cmd *cobra.Command, args []string) (any, error) { + f, _ := cmd.Flags().GetString("format") + if f == "" { + f = defaultFormat + } + if !allowedSet[f] { + return nil, fmt.Errorf("Invalid format: %s. The supported formats are: %s.", + f, strings.Join(allowed, ", ")) + } + cmd.SetContext(context.WithValue(cmd.Context(), formatKey{}, output.Format(f))) + data, err := next(cmd, args) + if err != nil { + return nil, err + } + return data, output.Render(cmd.OutOrStdout(), output.Format(f), data) + } + } +} + +func ensureFormatFlag(cmd *cobra.Command, defaultFormat string) { + if cmd.Flags().Lookup("format") == nil { + cmd.Flags().String("format", defaultFormat, + "Render output in a particular format.") + } +} + +// FormatFromContext returns the format resolved by WithFormat, or empty. +func FormatFromContext(ctx context.Context) output.Format { + if v, ok := ctx.Value(formatKey{}).(output.Format); ok { + return v + } + return "" +} diff --git a/internal/appctx/format_test.go b/internal/appctx/format_test.go new file mode 100644 index 000000000..3f18fd6e0 --- /dev/null +++ b/internal/appctx/format_test.go @@ -0,0 +1,75 @@ +package appctx + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// TestWithFormatRegistersFlagBeforeParse pins the bug where ensureFormatFlag +// was called lazily inside the RunE closure. With the fix, WithFormat must +// register --format at apply time so cobra can parse it before RunE runs. +func TestWithFormatRegistersFlagBeforeParse(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + WithFormat(cmd, "table", "table", "json") + if cmd.Flag("format") == nil { + t.Fatal("WithFormat must register --format at apply time, not lazily inside RunE") + } +} + +func TestWithFormatDefaultsRendersTable(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(context.Background()) + + mw := WithFormat(cmd, "table", "table", "csv", "json") + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + return output.OrderedRows{{{Key: "id", Value: 1}}}, nil + }) + if _, err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(buf.String(), "1") { + t.Errorf("table output missing data: %s", buf.String()) + } +} + +func TestWithFormatRejectsUnknownFormat(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetContext(context.Background()) + cmd.Flags().String("format", "yaml", "") + mw := WithFormat(cmd, "table", "table", "csv") + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + t.Error("handler must not run on rejected format") + return nil, nil + }) + _, err := run(cmd, nil) + wantSubstr := "Invalid format: yaml. The supported formats are: table, csv." + if err == nil || !strings.Contains(err.Error(), wantSubstr) { + t.Errorf("err = %v, want contains %q", err, wantSubstr) + } +} + +func TestWithFormatExposesViaFormatFromContext(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetContext(context.Background()) + cmd.Flags().String("format", "json", "") + mw := WithFormat(cmd, "table", "table", "json") + var seen output.Format + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + seen = FormatFromContext(cmd.Context()) + return nil, nil + }) + if _, err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if seen != output.FormatJSON { + t.Errorf("FormatFromContext = %q, want json", seen) + } +} diff --git a/internal/appctx/interactive.go b/internal/appctx/interactive.go new file mode 100644 index 000000000..667a16d9a --- /dev/null +++ b/internal/appctx/interactive.go @@ -0,0 +1,50 @@ +package appctx + +import ( + "os" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// IsInteractive reports whether prompts and browser opens are appropriate for +// the current invocation. Single source of truth for anything cobra drives; it +// replaced defensivemode.IsInteractive, since deleted along with the rest of an +// unused helper file. rechallenge.IsInteractiveContext survives as the fallback +// for the step-up middleware, which is built before any command is parsed and +// so has no *cobra.Command to read. +// +// Precedence: +// 1. VIP_NON_INTERACTIVE=1 -> false +// 2. --non-interactive flag (on cmd or any ancestor via PersistentFlags) -> false +// 3. stdin is a TTY -> true; otherwise false. +// +// The sensor is STDIN because that is the descriptor an answer has to arrive +// on. It used to be stdout, which meant `vip sync … | tee`, `> log` or `| less` +// reported "Command cancelled" and exited 0 with the mutation never issued +// (parity blocker B5). Node's enquirer reads stdin and is likewise unaffected +// by stdout redirection. +// +// This is deliberately NOT the same question as "can I render progress?" — +// that one is about where bytes are safe to draw and is sensed separately on +// os.Stderr (commands/progress_renderer.go, commands/sync.go), so that progress +// stays out of a redirected stdout and can never corrupt --format json. +func IsInteractive(cmd *cobra.Command) bool { + return isInteractiveCheck(cmd, term.IsTerminal(int(os.Stdin.Fd()))) +} + +func isInteractiveCheck(cmd *cobra.Command, tty bool) bool { + if os.Getenv("VIP_NON_INTERACTIVE") == "1" { + return false + } + if cmd != nil { + // cmd.Flag walks the local + persistent flag tables — covers ancestors + // when PersistentFlags propagation has merged through ParseFlags. Read + // the value off the *pflag.Flag directly (Flags().GetBool fails before + // Cobra's lazy persistent-flag merge has run). + if f := cmd.Flag("non-interactive"); f != nil && f.Changed && f.Value.String() == "true" { + return false + } + } + return tty +} diff --git a/internal/appctx/interactive_test.go b/internal/appctx/interactive_test.go new file mode 100644 index 000000000..9a1f790c8 --- /dev/null +++ b/internal/appctx/interactive_test.go @@ -0,0 +1,134 @@ +package appctx + +import ( + "os" + "testing" + + "github.com/creack/pty" + "github.com/spf13/cobra" +) + +// swapStdio points os.Stdin/os.Stdout at the given files for the duration of +// the test. The existing isInteractiveCheck tests inject the tty bool, so they +// pass no matter WHICH descriptor the real IsInteractive senses — these two +// tests pin that down. +func swapStdio(t *testing.T, in, out *os.File) { + t.Helper() + origIn, origOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = in, out + t.Cleanup(func() { os.Stdin, os.Stdout = origIn, origOut }) +} + +func openPTY(t *testing.T) *os.File { + t.Helper() + ptmx, tty, err := pty.Open() + if err != nil { + t.Skipf("pty unavailable: %v", err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + return tty +} + +func regularFile(t *testing.T) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "redirected") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func interactiveTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + return cmd +} + +// Regression for parity blocker B5. Interactivity was sensed on os.Stdout, so +// `vip sync … > log`, `| tee` or `| less` printed "Command cancelled" and exited +// 0 with the mutation never issued — the user believed the sync had run. Node's +// enquirer reads stdin and is unaffected by stdout redirection. +func TestIsInteractiveSensesStdinNotStdout(t *testing.T) { + swapStdio(t, openPTY(t), regularFile(t)) + if !IsInteractive(interactiveTestCmd()) { + t.Error("stdin is a TTY and only stdout is redirected: prompting must still be possible") + } +} + +// The converse: a piped stdin cannot answer a prompt, even when stdout is a +// terminal (`vip sync < /dev/null` must not block waiting for an answer). +func TestIsInteractiveFalseWhenStdinIsNotATTY(t *testing.T) { + swapStdio(t, regularFile(t), openPTY(t)) + if IsInteractive(interactiveTestCmd()) { + t.Error("stdin is not a TTY: prompting is impossible regardless of stdout") + } +} + +func TestIsInteractiveDefaults(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if got := isInteractiveCheck(cmd, true); !got { + t.Errorf("interactive in a TTY with no overrides should be true, got %v", got) + } + if got := isInteractiveCheck(cmd, false); got { + t.Errorf("non-TTY should be false") + } +} + +func TestIsInteractiveHonorsFlag(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + // Set via the PersistentFlags bucket the flag was defined on. Cobra only + // merges persistent flags into the local Flags() set lazily (during + // ParseFlags/Execute), so a direct Flags().Set on a never-executed command + // would fail. By real-command-execution time the merge has happened and + // cmd.Flag("non-interactive") finds it regardless. + if err := cmd.PersistentFlags().Set("non-interactive", "true"); err != nil { + t.Fatalf("flag set: %v", err) + } + if isInteractiveCheck(cmd, true) { + t.Error("--non-interactive must disable interactivity even on TTY") + } +} + +func TestIsInteractiveHonorsEnv(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if isInteractiveCheck(cmd, true) { + t.Error("VIP_NON_INTERACTIVE=1 must disable interactivity") + } +} + +// PersistentFlags propagation: a flag defined on parent must be honored when +// the test passes the child command in. (Cobra resolves PersistentFlags +// through cmd.Flag() on subcommands; this test pins the contract that the +// implementation walks the command tree correctly.) +func TestIsInteractiveHonorsPersistentFlagOnParent(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + parent.PersistentFlags().Bool("non-interactive", false, "") + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + // Cobra normally executes the full command tree (which propagates flags); + // in unit tests we trigger the merge by calling Execute or ParseFlags. + parent.SetArgs([]string{"child", "--non-interactive=true"}) + if err := parent.Execute(); err != nil { + // child has no RunE — Execute returns the "no RunE" error or similar; + // that's fine, we only need the flag-parsing side-effect. + _ = err + } + if isInteractiveCheck(child, true) { + t.Error("--non-interactive defined on parent (PersistentFlags) must disable interactivity for child") + } +} + +func TestIsInteractiveNilCmd(t *testing.T) { + // Defensive: a nil cobra command shouldn't panic; treat as if no flag is set. + if got := isInteractiveCheck(nil, true); !got { + t.Errorf("nil cmd + TTY should default to interactive=true, got %v", got) + } + if got := isInteractiveCheck(nil, false); got { + t.Errorf("nil cmd + non-TTY should be false, got %v", got) + } +} diff --git a/internal/appctx/middleware.go b/internal/appctx/middleware.go new file mode 100644 index 000000000..259c623e0 --- /dev/null +++ b/internal/appctx/middleware.go @@ -0,0 +1,56 @@ +// Package appctx composes command middleware. In M2 the only middleware is +// WithTelemetry; later milestones add WithAppContext / WithEnvContext / +// WithFormat / WithConfirm. Spec §4.3. +package appctx + +import "github.com/spf13/cobra" + +type RunFunc func(cmd *cobra.Command, args []string) error + +type Middleware func(next RunFunc) RunFunc + +type Builder struct { + cmd *cobra.Command + middleware []Middleware +} + +func Build(cmd *cobra.Command, mw ...Middleware) *Builder { + return &Builder{cmd: cmd, middleware: mw} +} + +func (b *Builder) WithRun(base RunFunc) *cobra.Command { + chain := base + for i := len(b.middleware) - 1; i >= 0; i-- { + chain = b.middleware[i](chain) + } + b.cmd.RunE = chain + return b.cmd +} + +// WithRenderableRun finalizes the builder for handlers that return (any, error). +// Use this when the chain includes WithFormat. Pure-error handlers use WithRun. +// +// The base RenderableRunFunc should ALREADY be wrapped with WithFormat (the +// innermost middleware closest to the handler) so output.Render runs against +// the data return. Builder's outer middleware slice receives a RunFunc +// adapter that discards the any return after rendering. +func (b *Builder) WithRenderableRun(base RenderableRunFunc) *cobra.Command { + finalAsRun := RunFunc(func(cmd *cobra.Command, args []string) error { + _, err := base(cmd, args) + return err + }) + return b.WithRun(finalAsRun) +} + +// Chain composes middlewares left-to-right: Chain(a, b)(next) == a(b(next)). +// Useful when a handler needs multiple middlewares but they're not wrapped +// by a Builder. +func Chain(mw ...Middleware) Middleware { + return func(next RunFunc) RunFunc { + chain := next + for i := len(mw) - 1; i >= 0; i-- { + chain = mw[i](chain) + } + return chain + } +} diff --git a/internal/appctx/middleware_test.go b/internal/appctx/middleware_test.go new file mode 100644 index 000000000..40a809b0b --- /dev/null +++ b/internal/appctx/middleware_test.go @@ -0,0 +1,88 @@ +package appctx + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMiddlewareChainExecutionOrder(t *testing.T) { + var calls []string + mw1 := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "mw1-before") + err := next(cmd, args) + calls = append(calls, "mw1-after") + return err + } + } + mw2 := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "mw2-before") + err := next(cmd, args) + calls = append(calls, "mw2-after") + return err + } + } + base := func(cmd *cobra.Command, args []string) error { + calls = append(calls, "handler") + return nil + } + cmd := &cobra.Command{Use: "test"} + wrapped := Build(cmd, mw1, mw2).WithRun(base) + if err := wrapped.RunE(wrapped, []string{}); err != nil { + t.Fatalf("RunE: %v", err) + } + want := []string{"mw1-before", "mw2-before", "handler", "mw2-after", "mw1-after"} + if !equalStrings(calls, want) { + t.Errorf("calls = %v, want %v", calls, want) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestChainComposesLeftToRight(t *testing.T) { + var calls []string + a := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "a-pre") + err := next(cmd, args) + calls = append(calls, "a-post") + return err + } + } + b := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "b-pre") + err := next(cmd, args) + calls = append(calls, "b-post") + return err + } + } + core := func(cmd *cobra.Command, args []string) error { + calls = append(calls, "core") + return nil + } + if err := Chain(a, b)(core)(&cobra.Command{}, nil); err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"a-pre", "b-pre", "core", "b-post", "a-post"} + if len(calls) != len(want) { + t.Fatalf("len(calls) = %d, want %d (calls=%v)", len(calls), len(want), calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("calls[%d] = %q, want %q", i, calls[i], want[i]) + } + } +} diff --git a/internal/appctx/prompts.go b/internal/appctx/prompts.go new file mode 100644 index 000000000..7015e0fe1 --- /dev/null +++ b/internal/appctx/prompts.go @@ -0,0 +1,70 @@ +package appctx + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" +) + +// ErrNonInteractive is returned when a prompt is requested in a non-interactive +// context and no fallback is available. Callers typically use errors.Is to +// detect this and convert to a "missing required flag" error. +var ErrNonInteractive = errors.New("non-interactive context: cannot prompt") + +// Confirm asks a yes/no question. Returns ErrNonInteractive when the session +// is non-interactive (caller decides whether to default-deny or fail). +func Confirm(cmd *cobra.Command, message string, defaultYes bool) (bool, error) { + return confirmCore(IsInteractive(cmd), os.Stderr, message, defaultYes) +} + +func confirmCore(interactive bool, stderr io.Writer, message string, defaultYes bool) (bool, error) { + if !interactive { + fmt.Fprintf(stderr, "Cannot prompt in non-interactive mode: %s\n", message) + return false, ErrNonInteractive + } + var out bool + prompt := &survey.Confirm{Message: message, Default: defaultYes} + if err := survey.AskOne(prompt, &out); err != nil { + return false, err + } + return out, nil +} + +// Input asks for a free-form string. If non-interactive and fallback is +// non-empty, returns the fallback; otherwise ErrNonInteractive. +func Input(cmd *cobra.Command, message, fallback string) (string, error) { + if !IsInteractive(cmd) { + if fallback != "" { + return fallback, nil + } + return "", ErrNonInteractive + } + var out string + prompt := &survey.Input{Message: message, Default: fallback} + if err := survey.AskOne(prompt, &out); err != nil { + return "", err + } + return out, nil +} + +// Select offers a list. options[0] is the default. Non-interactive with at +// least one option returns options[0]; non-interactive with no options +// returns ErrNonInteractive. +func Select(cmd *cobra.Command, message string, options []string) (string, error) { + if !IsInteractive(cmd) { + if len(options) > 0 { + return options[0], nil + } + return "", ErrNonInteractive + } + var out string + prompt := &survey.Select{Message: message, Options: options, Default: options[0]} + if err := survey.AskOne(prompt, &out); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/appctx/prompts_test.go b/internal/appctx/prompts_test.go new file mode 100644 index 000000000..49b3ee9e2 --- /dev/null +++ b/internal/appctx/prompts_test.go @@ -0,0 +1,97 @@ +package appctx + +import ( + "bytes" + "errors" + "testing" + + "github.com/spf13/cobra" +) + +// makeNonInteractiveCmd builds a cobra command with --non-interactive=true +// set via PersistentFlags (the bucket the flag was defined on — see Task 6 +// for why this matters in pre-Execute test scenarios). +func makeNonInteractiveCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if err := cmd.PersistentFlags().Set("non-interactive", "true"); err != nil { + t.Fatalf("set --non-interactive: %v", err) + } + return cmd +} + +func TestConfirmNonInteractiveReturnsErr(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Confirm(cmd, "delete the world?", false) + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != false { + t.Errorf("got = %v, want false", got) + } +} + +func TestInputNonInteractiveErrorsWithoutDefault(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Input(cmd, "value?", "") + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != "" { + t.Errorf("got = %q, want empty", got) + } +} + +func TestInputNonInteractiveAllowsDefault(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Input(cmd, "value?", "fallback") + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "fallback" { + t.Errorf("Input = %q, want fallback", got) + } +} + +func TestSelectNonInteractiveReturnsFirst(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Select(cmd, "pick:", []string{"a", "b", "c"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "a" { + t.Errorf("Select = %q, want a", got) + } +} + +func TestSelectNonInteractiveEmptyOptionsErr(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Select(cmd, "pick:", nil) + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != "" { + t.Errorf("got = %q, want empty", got) + } +} + +// confirmCore must print to the provided writer in non-interactive mode and +// return ErrNonInteractive. This pins the contract that the wrapper warns +// the operator before failing. +func TestConfirmCoreNonInteractiveWritesStderr(t *testing.T) { + var stderr bytes.Buffer + got, err := confirmCore(false /*interactive*/, &stderr, "test message", true) + if got != false { + t.Errorf("got = %v, want false", got) + } + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if stderr.Len() == 0 { + t.Error("confirmCore non-interactive must write something to stderr") + } + if !bytes.Contains(stderr.Bytes(), []byte("test message")) { + t.Errorf("stderr = %q, want it to include the prompt message", stderr.String()) + } +} diff --git a/internal/appctx/required_args.go b/internal/appctx/required_args.go new file mode 100644 index 000000000..2947fa92f --- /dev/null +++ b/internal/appctx/required_args.go @@ -0,0 +1,24 @@ +package appctx + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// WithRequiredArgs enforces an exact positional-arg count. On mismatch returns +// a Node-parity error: "Please supply N argument(s): <command usage>". +func WithRequiredArgs(n int) Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + if len(args) != n { + word := "arguments" + if n == 1 { + word = "argument" + } + return fmt.Errorf("Please supply %d %s: %s", n, word, cmd.UseLine()) + } + return next(cmd, args) + } + } +} diff --git a/internal/appctx/required_args_test.go b/internal/appctx/required_args_test.go new file mode 100644 index 000000000..ba7959a83 --- /dev/null +++ b/internal/appctx/required_args_test.go @@ -0,0 +1,38 @@ +package appctx + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithRequiredArgsAccepts(t *testing.T) { + cmd := &cobra.Command{Use: "get <NAME>"} + cmd.SetContext(context.Background()) + mw := WithRequiredArgs(1) + run := mw(func(cmd *cobra.Command, args []string) error { + if len(args) != 1 || args[0] != "FOO" { + t.Errorf("args = %v", args) + } + return nil + }) + if err := run(cmd, []string{"FOO"}); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithRequiredArgsRejects(t *testing.T) { + cmd := &cobra.Command{Use: "get <NAME>"} + cmd.SetContext(context.Background()) + mw := WithRequiredArgs(1) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("handler must not run when arg count is wrong") + return nil + }) + err := run(cmd, []string{}) + if err == nil || !strings.Contains(err.Error(), "Please supply 1 argument") { + t.Errorf("err = %v, want Node-parity supply-argument error", err) + } +} diff --git a/internal/appctx/telemetry.go b/internal/appctx/telemetry.go new file mode 100644 index 000000000..29c92ad08 --- /dev/null +++ b/internal/appctx/telemetry.go @@ -0,0 +1,22 @@ +package appctx + +import "github.com/spf13/cobra" + +type CommandTracker interface { + MakeCommandTracker(command string, info map[string]any) func(eventType string, data map[string]any) +} + +func WithTelemetry(tr CommandTracker, command string, info map[string]any) Middleware { + track := tr.MakeCommandTracker(command, info) + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + track("execute", nil) + if err := next(cmd, args); err != nil { + track("error", map[string]any{"error": err.Error()}) + return err + } + track("success", nil) + return nil + } + } +} diff --git a/internal/appctx/telemetry_test.go b/internal/appctx/telemetry_test.go new file mode 100644 index 000000000..e718ffbc2 --- /dev/null +++ b/internal/appctx/telemetry_test.go @@ -0,0 +1,42 @@ +package appctx + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" +) + +type fakeTracker struct { + events []string +} + +func (f *fakeTracker) MakeCommandTracker(cmd string, info map[string]any) func(string, map[string]any) { + return func(eventType string, data map[string]any) { + f.events = append(f.events, cmd+"_"+eventType) + } +} + +func TestWithTelemetryEmitsExecuteAndSuccess(t *testing.T) { + tr := &fakeTracker{} + cmd := &cobra.Command{Use: "demo"} + wrapped := Build(cmd, WithTelemetry(tr, "demo", nil)).WithRun(func(cmd *cobra.Command, args []string) error { return nil }) + if err := wrapped.RunE(wrapped, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if len(tr.events) != 2 || tr.events[0] != "demo_execute" || tr.events[1] != "demo_success" { + t.Errorf("events = %v", tr.events) + } +} + +func TestWithTelemetryEmitsErrorOnFailure(t *testing.T) { + tr := &fakeTracker{} + cmd := &cobra.Command{Use: "demo"} + wrapped := Build(cmd, WithTelemetry(tr, "demo", nil)).WithRun(func(cmd *cobra.Command, args []string) error { + return errors.New("boom") + }) + wrapped.RunE(wrapped, nil) + if len(tr.events) != 2 || tr.events[1] != "demo_error" { + t.Errorf("events = %v, want [execute, error]", tr.events) + } +} diff --git a/internal/appctx/wildcard.go b/internal/appctx/wildcard.go new file mode 100644 index 000000000..7d38fbd82 --- /dev/null +++ b/internal/appctx/wildcard.go @@ -0,0 +1,29 @@ +package appctx + +import ( + "github.com/spf13/cobra" +) + +// WithWildcardCommand registers a fallback handler on a Cobra parent. When the +// parent is invoked with positional args whose first element is NOT the name +// of a registered subcommand, the fallback runs with those args. Mirrors +// Node's _opts.wildcardCommand pattern. +// +// MUST be called after all real subcommands are added to parent (snapshots +// their names at call time). +func WithWildcardCommand(parent *cobra.Command, fallback RunFunc) { + subNames := map[string]bool{} + for _, c := range parent.Commands() { + subNames[c.Name()] = true + for _, alias := range c.Aliases { + subNames[alias] = true + } + } + parent.Args = cobra.ArbitraryArgs + parent.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && subNames[args[0]] { + return cmd.Help() + } + return fallback(cmd, args) + } +} diff --git a/internal/appctx/wildcard_test.go b/internal/appctx/wildcard_test.go new file mode 100644 index 000000000..0f794cf10 --- /dev/null +++ b/internal/appctx/wildcard_test.go @@ -0,0 +1,53 @@ +package appctx + +import ( + "context" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithWildcardCommandRoutesUnknownToFallback(t *testing.T) { + parent := &cobra.Command{Use: "app"} + knownSub := &cobra.Command{Use: "list", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + parent.AddCommand(knownSub) + + var calledWith []string + WithWildcardCommand(parent, func(cmd *cobra.Command, args []string) error { + calledWith = args + return nil + }) + + parent.SetArgs([]string{"example-app"}) + parent.SetContext(context.Background()) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if len(calledWith) != 1 || calledWith[0] != "example-app" { + t.Errorf("fallback received args=%v, want [example-app]", calledWith) + } +} + +func TestWithWildcardCommandDispatchesKnownSubcommand(t *testing.T) { + parent := &cobra.Command{Use: "app"} + var listCalled bool + knownSub := &cobra.Command{Use: "list", RunE: func(cmd *cobra.Command, args []string) error { + listCalled = true + return nil + }} + parent.AddCommand(knownSub) + + WithWildcardCommand(parent, func(cmd *cobra.Command, args []string) error { + t.Error("fallback must not run when a real subcommand is invoked") + return nil + }) + + parent.SetArgs([]string{"list"}) + parent.SetContext(context.Background()) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !listCalled { + t.Error("list subcommand was not dispatched") + } +} diff --git a/internal/auth/bypass.go b/internal/auth/bypass.go new file mode 100644 index 000000000..50a4ad9aa --- /dev/null +++ b/internal/auth/bypass.go @@ -0,0 +1,84 @@ +package auth + +import ( + "os" + "strings" +) + +// ShouldBypassAuth reports whether this invocation may run WITHOUT an +// interactive login. It is the port of the argv scan in src/bin/vip.js:190-212. +// +// Scope matters more than the token list: in Node this decides exactly one +// thing — login flow, or not. Either way `runCmd()` gets full API access, +// because src/lib/api/http.ts re-reads the keychain on every request. A true +// return here therefore means "do not prompt", NOT "do not configure the API +// client"; main.go must still hand the command whatever token is stored. +// +// Node's scan really is flat over the whole argv (doesArgvHaveAtLeastOneParam +// is `argv.some(arg => params.includes(arg))`), so `config envvar get help` +// takes this branch on both CLIs. That is only benign because of the rule +// above. +func ShouldBypassAuth(argv []string) bool { + hasHelp := contains(argv, "help", "-h", "--help") + hasVersion := contains(argv, "-v", "--version") + hasLogout := contains(argv, "logout") + hasLogin := contains(argv, "login") + hasDevEnv := contains(argv, "dev-env") + hasSync := contains(argv, "sync") + hasDeploy := contains(argv, "deploy") + hasAppEnv := containsAppEnvArgument(argv) + if hasHelp || hasVersion || hasLogout || hasLogin { + return true + } + // vip.js:196-198 — isDevEnvCommandWithoutEnv. `hasSync` is vip-next-only: + // `dev-env sync sql` pulls a production export, so it gets a login prompt + // instead of Node's bare 401. + if hasDevEnv && !hasAppEnv && !hasSync { + return true + } + if hasDeploy && os.Getenv("WPVIP_DEPLOY_TOKEN") != "" { + return true + } + return false +} + +func contains(argv []string, needles ...string) bool { + set := map[string]struct{}{} + for _, n := range needles { + set[n] = struct{}{} + } + for _, a := range argv { + if _, ok := set[a]; ok { + return true + } + } + return false +} + +// containsAppEnvArgument ports containsAppEnvArgument +// (src/lib/cli/command.js:1128-1134): +// +// parsedAlias.app || parsedAlias.env || argv.includes('--app') || argv.includes('--env') +// +// The two halves have deliberately different reach, and both are reproduced: +// parseEnvAliasFromArgv only looks BEFORE `--` (envAlias.ts:41-47), while the +// flag check is a plain exact-token scan of the whole argv. Consequences, all +// Node's: `--app=example` is missed, and a `--app` after `--` counts. +func containsAppEnvArgument(argv []string) bool { + if containsAlias(argv) { + return true + } + return contains(argv, "--app", "--env") +} + +func containsAlias(argv []string) bool { + for _, a := range argv { + if a == "--" { + return false + } + if strings.HasPrefix(a, "@") && len(a) > 1 { + return true + } + } + return false +} diff --git a/internal/auth/bypass_test.go b/internal/auth/bypass_test.go new file mode 100644 index 000000000..4487adfda --- /dev/null +++ b/internal/auth/bypass_test.go @@ -0,0 +1,97 @@ +package auth + +import ( + "os" + "testing" +) + +func TestShouldBypassAuth(t *testing.T) { + tests := []struct { + name string + argv []string + env map[string]string + want bool + }{ + {"help short", []string{"--help"}, nil, true}, + {"help long", []string{"app", "list", "--help"}, nil, true}, + {"help word", []string{"help"}, nil, true}, + {"-h", []string{"-h"}, nil, true}, + {"version short", []string{"-v"}, nil, true}, + {"version long", []string{"--version"}, nil, true}, + {"logout", []string{"logout"}, nil, true}, + {"dev-env no alias", []string{"dev-env", "start"}, nil, true}, + {"dev-env with alias", []string{"dev-env", "@my-app", "destroy"}, nil, false}, + {"deploy with env token", []string{"app", "deploy"}, map[string]string{"WPVIP_DEPLOY_TOKEN": "x"}, true}, + {"deploy without env token", []string{"app", "deploy"}, nil, false}, + {"plain command", []string{"app", "list"}, nil, false}, + {name: "login bypasses", argv: []string{"login"}, want: true}, + {name: "login with flags bypasses", argv: []string{"login", "--debug"}, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, set := tc.env["WPVIP_DEPLOY_TOKEN"]; !set { + os.Unsetenv("WPVIP_DEPLOY_TOKEN") + } + for k, v := range tc.env { + t.Setenv(k, v) + } + got := ShouldBypassAuth(tc.argv) + if got != tc.want { + t.Errorf("ShouldBypassAuth(%v) = %v, want %v", tc.argv, got, tc.want) + } + }) + } +} + +func TestDevEnvSyncRequiresAuth(t *testing.T) { + if ShouldBypassAuth([]string{"dev-env", "sync", "sql", "--slug", "x"}) { + t.Fatal("dev-env sync must NOT bypass auth (it calls the platform)") + } +} + +// TestDevEnvWithAppEnvFlagsRequiresAuth pins Node's containsAppEnvArgument +// (src/lib/cli/command.js:1128-1134), which counts BOTH the @app.env alias and +// the bare --app/--env flags. vip-next only looked for the alias, so +// `dev-env create --app example` skipped auth and the create wizard silently +// lost every app-derived default. +func TestDevEnvWithAppEnvFlagsRequiresAuth(t *testing.T) { + cases := [][]string{ + {"dev-env", "create", "--app", "example"}, + {"dev-env", "create", "--env", "develop"}, + } + for _, argv := range cases { + if ShouldBypassAuth(argv) { + t.Errorf("ShouldBypassAuth(%v) = true; --app/--env is an app/env argument in Node", argv) + } + } +} + +// TestDevEnvAppEnvArgumentMatchesNodeExactTokenScan pins the two ways Node's +// containsAppEnvArgument is *sloppier* than the alias parser it wraps. +// `argv.includes('--app')` is an exact-token, whole-argv scan, so: +// +// - `--app=example` is NOT recognised (Node bug: the wizard bypasses login), +// whereas the alias half of the same function stops at `--`; +// - a `--app` token appearing AFTER `--` IS recognised. +// +// Both are Node's shipping behaviour. They are harmless in practice because a +// bypassed invocation still gets a configured API client on both CLIs (Node +// loads the token per request in api/http.ts) — it only decides whether an +// unauthenticated user gets a login prompt or a 401. +func TestDevEnvAppEnvArgumentMatchesNodeExactTokenScan(t *testing.T) { + if !ShouldBypassAuth([]string{"dev-env", "create", "--app=example"}) { + t.Error("Node's argv.includes('--app') does not match the --app=value form") + } + if ShouldBypassAuth([]string{"dev-env", "exec", "--", "wp", "option", "get", "--app"}) { + t.Error("Node's flag scan is not bounded by --; a later --app still counts") + } +} + +func TestDevEnvNonSyncStillBypasses(t *testing.T) { + if !ShouldBypassAuth([]string{"dev-env", "start", "--slug", "x"}) { + t.Fatal("dev-env start should still bypass auth") + } + if !ShouldBypassAuth([]string{"dev-env", "import", "sql", "f.sql"}) { + t.Fatal("dev-env import should still bypass auth") + } +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 000000000..b3f543230 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,156 @@ +package auth + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/pkg/browser" +) + +// TokenURL is the VIP dashboard URL where users retrieve their Personal Access Token. +const TokenURL = "https://dashboard.wpvip.com/me/cli/token" + +// ErrLoginCancelled is returned when the user declines the "Ready to authenticate?" prompt. +var ErrLoginCancelled = errors.New("login: cancelled by user") + +// Sentinels for the already-messaged validation failures (the flow prints the +// user-facing line; the command treats these as a clean exit, Node parity). +var ( + ErrTokenMalformed = errors.New("login: token malformed") + ErrTokenExpired = errors.New("login: token expired") + ErrTokenInvalid = errors.New("login: token invalid") +) + +// IsHandledLoginError reports whether err is a validation failure the flow +// already reported to the user (so the command should exit 0). +func IsHandledLoginError(err error) bool { + return errors.Is(err, ErrTokenMalformed) || + errors.Is(err, ErrTokenExpired) || + errors.Is(err, ErrTokenInvalid) +} + +// Tracker abstracts telemetry so tests can record events without a real client. +type Tracker interface { + Track(name string, props map[string]any) +} + +// LoginFlow holds the injectable dependencies for the login sequence. +// All function fields are optional in tests; nil Tracker/SaveToken/Alias are silently skipped. +type LoginFlow struct { + Stdout io.Writer + Tracker Tracker + Confirm func(prompt string) (bool, error) + OpenURL func(url string) error + ReadToken func() (string, error) + SaveToken func(rawJWT string) error + Alias func(userID int64) +} + +// NewProductionLoginFlow wires real I/O: survey prompts, system browser, keychain store. +func NewProductionLoginFlow(store *Store, tracker Tracker, alias func(int64)) *LoginFlow { + return &LoginFlow{ + Stdout: os.Stdout, + Tracker: tracker, + Confirm: surveyConfirm, + OpenURL: browser.OpenURL, + ReadToken: surveyPasswordReadToken, + SaveToken: store.Save, + Alias: alias, + } +} + +// Run executes the interactive login flow. +// Apart from the vip-next-specific banner, it mirrors src/bin/vip.js lines 92–178. +func (l *LoginFlow) Run() (*Token, error) { + // Print banner: empty line, gradient ANSI art, empty line, subtitle, empty + // line, authenticate line with token URL, empty line. + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, "\x1b[38;2;232;196;142m ██╗ ██╗██╗██████╗ ██████╗██╗ ██╗ ███████╗\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;224;181;118m ██║ ██║██║██╔══██╗ ██╔════╝██║ ██║ ██╔════╝\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;216;164;95m ██║ ██║██║██████╔╝█████╗██║ ██║ ██║ ███████╗\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;205;150;78m ╚██╗ ██╔╝██║██╔═══╝ ╚════╝██║ ██║ ██║ ╚════██║\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;195;137;60m ╚████╔╝ ██║██║ ╚██████╗███████╗██║ ███████║\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;185;124;45m ╚═══╝ ╚═╝╚═╝ ╚═════╝╚══════╝╚═╝ ╚══════╝\x1b[0m") + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, ` VIP-CLI is your tool for interacting with and managing your VIP applications.`) + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, ` Authenticate your installation of VIP-CLI with your Personal Access Token. This URL will be opened in your web browser automatically so that you can retrieve your token: `+TokenURL) + fmt.Fprintln(l.Stdout) + + l.track("login_command_execute", nil) + + ok, err := l.Confirm("Ready to authenticate?") + if err != nil { + return nil, err + } + if !ok { + l.track("login_command_browser_cancelled", nil) + return nil, ErrLoginCancelled + } + + if err := l.OpenURL(TokenURL); err != nil { + l.track("login_command_browser_error", map[string]any{"error": err.Error()}) + } else { + l.track("login_command_browser_opened", nil) + } + + rawInput, err := l.ReadToken() + if err != nil { + return nil, err + } + + tok, err := ParseToken(rawInput) + if err != nil { + fmt.Fprintln(l.Stdout, "The token provided is malformed. Please check the token and try again.") + l.track("login_command_token_submit_error", map[string]any{"error": err.Error()}) + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + + if tok.Expired() { + fmt.Fprintln(l.Stdout, "The token provided is expired. Please log in again to refresh the token.") + l.track("login_command_token_submit_error", map[string]any{"error": "expired"}) + return nil, ErrTokenExpired + } + + if !tok.Valid() { + fmt.Fprintln(l.Stdout, "The provided token is not valid. Please log in again to refresh the token.") + l.track("login_command_token_submit_error", map[string]any{"error": "invalid"}) + return nil, ErrTokenInvalid + } + + if l.SaveToken != nil { + if err := l.SaveToken(tok.Raw); err != nil { + l.track("login_command_token_submit_error", map[string]any{"error": err.Error()}) + return nil, err + } + } + + if l.Alias != nil { + l.Alias(tok.ID) + } + + l.track("login_command_token_submit_success", nil) + return tok, nil +} + +func (l *LoginFlow) track(name string, props map[string]any) { + if l.Tracker == nil { + return + } + l.Tracker.Track(name, props) +} + +func surveyConfirm(prompt string) (bool, error) { + var ans bool + err := survey.AskOne(&survey.Confirm{Message: prompt}, &ans) + return ans, err +} + +func surveyPasswordReadToken() (string, error) { + var token string + err := survey.AskOne(&survey.Password{Message: "Access Token:"}, &token) + return token, err +} diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go new file mode 100644 index 000000000..207f956ca --- /dev/null +++ b/internal/auth/login_test.go @@ -0,0 +1,184 @@ +package auth + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" +) + +type fakeTracker struct { + events []string + props []map[string]any +} + +func (f *fakeTracker) Track(name string, props map[string]any) { + f.events = append(f.events, name) + f.props = append(f.props, props) +} + +func TestLoginPrintsBannerAndTokenURL(t *testing.T) { + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return false, nil }, + OpenURL: func(string) error { + t.Fatal("OpenURL must not be called") + return nil + }, + ReadToken: func() (string, error) { + t.Fatal("ReadToken must not be called") + return "", nil + }, + } + _, err := lf.Run() + if !errors.Is(err, ErrLoginCancelled) { + t.Errorf("expected ErrLoginCancelled, got %v", err) + } + out := stdout.String() + wantBanner := "\n" + + "\x1b[38;2;232;196;142m ██╗ ██╗██╗██████╗ ██████╗██╗ ██╗ ███████╗\x1b[0m\n" + + "\x1b[38;2;224;181;118m ██║ ██║██║██╔══██╗ ██╔════╝██║ ██║ ██╔════╝\x1b[0m\n" + + "\x1b[38;2;216;164;95m ██║ ██║██║██████╔╝█████╗██║ ██║ ██║ ███████╗\x1b[0m\n" + + "\x1b[38;2;205;150;78m ╚██╗ ██╔╝██║██╔═══╝ ╚════╝██║ ██║ ██║ ╚════██║\x1b[0m\n" + + "\x1b[38;2;195;137;60m ╚████╔╝ ██║██║ ╚██████╗███████╗██║ ███████║\x1b[0m\n" + + "\x1b[38;2;185;124;45m ╚═══╝ ╚═╝╚═╝ ╚═════╝╚══════╝╚═╝ ╚══════╝\x1b[0m\n\n" + if !strings.HasPrefix(out, wantBanner) { + t.Errorf("new VIP-CLI 5 banner missing:\n%s", out) + } + if !strings.Contains(out, "VIP-CLI is your tool for interacting with and managing your VIP applications.") { + t.Errorf("banner subtitle missing: %q", out) + } + if !strings.Contains(out, "https://dashboard.wpvip.com/me/cli/token") { + t.Errorf("token URL missing: %q", out) + } + if len(tr.events) != 2 || tr.events[0] != "login_command_execute" || tr.events[1] != "login_command_browser_cancelled" { + t.Errorf("events = %v", tr.events) + } +} + +func TestLoginAcceptsValidToken(t *testing.T) { + iat := time.Now().Add(-time.Hour).Unix() + exp := time.Now().Add(time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + openCalled := false + saved := "" + aliased := int64(0) + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(u string) error { openCalled = true; return nil }, + ReadToken: func() (string, error) { return raw, nil }, + SaveToken: func(s string) error { saved = s; return nil }, + Alias: func(id int64) { aliased = id }, + } + tok, err := lf.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if !openCalled { + t.Error("OpenURL must be called") + } + if tok.ID != 7 { + t.Errorf("tok.ID = %d, want 7", tok.ID) + } + if saved != raw { + t.Errorf("SaveToken not invoked correctly") + } + if aliased != 7 { + t.Errorf("Alias = %d, want 7", aliased) + } + wantEvents := []string{"login_command_execute", "login_command_browser_opened", "login_command_token_submit_success"} + if !equalStringSlices(tr.events, wantEvents) { + t.Errorf("events = %v, want %v", tr.events, wantEvents) + } +} + +func TestLoginPersistenceFailurePreventsAlias(t *testing.T) { + iat := time.Now().Add(-time.Hour).Unix() + exp := time.Now().Add(time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + aliasCalled := false + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return raw, nil }, + SaveToken: func(string) error { return errors.New("save failed") }, + Alias: func(int64) { aliasCalled = true }, + } + if _, err := lf.Run(); err == nil || err.Error() != "save failed" { + t.Fatalf("Run error = %v, want save failed", err) + } + if aliasCalled { + t.Fatal("Alias must not run after persistence failure") + } + if tr.events[len(tr.events)-1] != "login_command_token_submit_error" { + t.Fatalf("last event = %v", tr.events) + } +} + +func TestLoginRejectsMalformedToken(t *testing.T) { + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return "garbage", nil }, + } + _, err := lf.Run() + if !errors.Is(err, ErrTokenMalformed) { + t.Fatalf("expected ErrTokenMalformed, got %v", err) + } + if !strings.Contains(stdout.String(), "The token provided is malformed. Please check the token and try again.") { + t.Errorf("malformed message missing: %q", stdout.String()) + } + if len(tr.events) < 1 || tr.events[len(tr.events)-1] != "login_command_token_submit_error" { + t.Errorf("last event = %v", tr.events) + } +} + +func TestLoginRejectsExpiredToken(t *testing.T) { + iat := time.Now().Add(-2 * time.Hour).Unix() + exp := time.Now().Add(-time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return raw, nil }, + } + _, err := lf.Run() + if !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } + if !strings.Contains(stdout.String(), "The token provided is expired. Please log in again to refresh the token.") { + t.Errorf("expired message missing: %q", stdout.String()) + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/auth/logout.go b/internal/auth/logout.go new file mode 100644 index 000000000..19c67c849 --- /dev/null +++ b/internal/auth/logout.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + "net/http" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// PostLogout best-effort invalidates the token server-side (Node logout.ts: +// http('/logout', {method:'post'})). The response status is intentionally +// ignored; only a transport failure returns a non-nil error. The caller always +// purges the local token regardless. +func PostLogout(apiHost, rawToken string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiHost+"/logout", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+rawToken) + // NOT http.DefaultClient: this request carries the bearer token, and Node + // routes /logout through api/http.ts's proxy agent. See internal/httpproxy. + resp, err := httpproxy.Client().Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} diff --git a/internal/auth/logout_test.go b/internal/auth/logout_test.go new file mode 100644 index 000000000..5bf5a20f5 --- /dev/null +++ b/internal/auth/logout_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPostLogoutSendsBearer(t *testing.T) { + var gotAuth, gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotMethod, gotPath = r.Header.Get("Authorization"), r.Method, r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + if err := PostLogout(srv.URL, "rawtok"); err != nil { + t.Fatalf("PostLogout: %v", err) + } + if gotAuth != "Bearer rawtok" || gotMethod != http.MethodPost || gotPath != "/logout" { + t.Errorf("got %q %q %q", gotMethod, gotPath, gotAuth) + } +} + +func TestPostLogoutIgnoresServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + if err := PostLogout(srv.URL, "tok"); err != nil { + t.Errorf("5xx should be ignored, got %v", err) + } +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 000000000..3e0cf9df9 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,130 @@ +package auth + +import ( + "errors" + "log/slog" + "os" + + "github.com/Automattic/vip/internal/keychain" +) + +var ErrNoToken = errors.New("auth: no token stored") + +const legacyFallbackDisabledValue = "1" + +type Store struct { + K *keychain.Keychain + // OnDelete is invoked after a successful Delete (right after the token is + // purged from keychain). Errors are logged at debug level but never returned, + // matching Node's logout flow which proceeds even when tokenCache.clearAll + // throws. Wire this in main.go to rechallenge.TokenCache.ClearAll. + OnDelete func() error +} + +func NewStore(k *keychain.Keychain) *Store { return &Store{K: k} } + +func (s *Store) Save(rawJWT string) error { + if err := s.K.Set(s.K.Account(), rawJWT); err != nil { + return err + } + err := s.K.Backend.Delete(s.K.Service, s.fallbackMarkerAccount()) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err +} + +func (s *Store) Load() (string, error) { + v, err := s.LoadPrimary() + if err == nil { + return v, nil + } + if !errors.Is(err, ErrNoToken) { + return "", err + } + if s.K.LegacyService == "" { + return "", ErrNoToken + } + if _, markerErr := s.K.Backend.Get(s.K.Service, s.fallbackMarkerAccount()); markerErr == nil { + return "", ErrNoToken + } else if !errors.Is(markerErr, keychain.ErrNotFound) { + return "", markerErr + } + v, err = s.K.Backend.Get(s.K.LegacyService, s.K.LegacyService) + if errors.Is(err, keychain.ErrNotFound) { + return "", ErrNoToken + } + return v, err +} + +// tokenOverride returns VIP_TOKEN_OVERRIDE, but only in test mode. +// +// Node gates the same variable on NODE_ENV=test (src/lib/token.ts:105). Go has +// no NODE_ENV, so the gate is GO_ENV=test — the equivalent this repo had already +// settled on before this change: internal/telemetry/tracker.go:83 opts telemetry +// out on GO_ENV=test, and internal/parity/env.go pins GO_ENV alongside NODE_ENV +// for every harness subprocess. NODE_ENV=test is accepted too, so a shell set up +// to drive both CLIs keeps working with one variable. +// +// Honest scope: this is NOT a security boundary. Anyone who can set +// VIP_TOKEN_OVERRIDE in this process's environment can set GO_ENV as well, and +// Node's gate is no stronger. What it does buy is the removal of a much likelier +// non-adversarial failure: a VIP_TOKEN_OVERRIDE left exported in a CI image, a +// shell profile or a .env from an earlier test run silently becoming the +// identity every real command authenticates as — including `logout`, which read +// the override to decide what to revoke but deleted the keychain credential, so +// the two were different tokens. +// +// A gate that an env-var-capable attacker could not defeat would have to be +// compile-time (a build tag, or testing.Testing()). Both were rejected: the +// parity harness drives the SHIPPING binary and needs the hatch, so a +// compile-time gate would mean shipping one binary and testing another. +func tokenOverride() string { + if os.Getenv("GO_ENV") != "test" && os.Getenv("NODE_ENV") != "test" { + return "" + } + return os.Getenv("VIP_TOKEN_OVERRIDE") +} + +// LoadPrimary returns only vip-next's credential (or, in test mode, an explicit +// override). Callers that mutate server-side session state, such as logout, must +// not act on the read-only legacy fallback returned by Load. +func (s *Store) LoadPrimary() (string, error) { + if override := tokenOverride(); override != "" { + return override, nil + } + v, err := s.K.Get(s.K.Account()) + if errors.Is(err, keychain.ErrNotFound) { + return "", ErrNoToken + } + return v, err +} + +func (s *Store) Delete() error { + err := s.K.Delete(s.K.Account()) + missing := errors.Is(err, keychain.ErrNotFound) + if err != nil && !missing { + return err + } + if markerErr := s.K.Backend.Set(s.K.Service, s.fallbackMarkerAccount(), legacyFallbackDisabledValue); markerErr != nil { + return markerErr + } + // Run the hook even when the primary token was already gone — elevated + // tokens may exist independently and need clearing. + if s.OnDelete != nil { + if hookErr := s.OnDelete(); hookErr != nil { + slog.Debug("auth.Store.Delete OnDelete hook failed", "err", hookErr) + } + // Hook wired: logout is idempotent (matches Node's logout.ts which + // proceeds regardless of token state). + return nil + } + if missing { + return ErrNoToken + } + return nil +} + +func (s *Store) fallbackMarkerAccount() string { + return s.K.Service + ":legacy-fallback-disabled" +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 000000000..f34b3663c --- /dev/null +++ b/internal/auth/store_test.go @@ -0,0 +1,339 @@ +package auth + +import ( + "errors" + "os" + "testing" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func newTestStore() *Store { + k := &keychain.Keychain{ + Backend: &memBackend{}, + Service: "vip-next-cli", + LegacyService: "vip-go-cli", + } + return NewStore(k) +} + +func TestStoreSaveAndLoad(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + if err := s.Save("jwt.payload.sig"); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "jwt.payload.sig" { + t.Errorf("Load = %q, want %q", got, "jwt.payload.sig") + } +} + +func TestStoreLoadFallsBackToLegacyWhenPrimaryMissing(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "legacy-token" { + t.Fatalf("Load = %q, want legacy-token", got) + } +} + +func TestStoreLoadPrimaryDoesNotReturnLegacyToken(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + if _, err := s.LoadPrimary(); !errors.Is(err, ErrNoToken) { + t.Fatalf("LoadPrimary = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadPrefersPrimaryEvenWhenInvalid(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "valid-legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := be.Set("vip-next-cli", "vip-next-cli", "invalid-primary"); err != nil { + t.Fatalf("seed primary token: %v", err) + } + + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "invalid-primary" { + t.Fatalf("Load = %q, want invalid-primary", got) + } +} + +func TestStoreSaveWritesOnlyPrimaryAndClearsFallbackMarker(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := be.Set("vip-next-cli", "vip-next-cli:legacy-fallback-disabled", "1"); err != nil { + t.Fatalf("seed fallback marker: %v", err) + } + + if err := s.Save("new-token"); err != nil { + t.Fatalf("Save: %v", err) + } + if got := be.store["vip-next-cli|vip-next-cli"]; got != "new-token" { + t.Fatalf("primary token = %q, want new-token", got) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("Save changed the legacy token to %q", got) + } + if _, ok := be.store["vip-next-cli|vip-next-cli:legacy-fallback-disabled"]; ok { + t.Fatal("Save did not clear the legacy-fallback marker") + } +} + +func TestStoreDeleteLeavesLegacyAndDisablesFallback(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := s.Save("primary-token"); err != nil { + t.Fatalf("Save: %v", err) + } + + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("legacy token = %q, want unchanged legacy-token", got) + } + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Load after Delete = %v, want ErrNoToken", err) + } +} + +func TestStoreDeleteWithoutPrimaryStillDisablesLegacyFallback(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + if err := s.Delete(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Delete without primary = %v, want ErrNoToken", err) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("legacy token = %q, want unchanged legacy-token", got) + } + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Load after Delete = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadMissingReturnsNotFound(t *testing.T) { + s := newTestStore() + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("err = %v, want ErrNoToken", err) + } +} + +func TestStoreDelete(t *testing.T) { + s := newTestStore() + s.Save("x") + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("after Delete: err = %v, want ErrNoToken", err) + } +} + +// TestStoreLoadIgnoresOverrideOutsideTestMode pins cutover item 2.15. +// Node honours VIP_TOKEN_OVERRIDE only under NODE_ENV=test +// (src/lib/token.ts:105); vip-next honoured it unconditionally, which turned a +// test escape hatch into a live production auth path. GO_ENV is the Go-side +// equivalent this repo already uses (internal/telemetry/tracker.go:83, +// internal/parity/env.go pins both). +func TestStoreLoadIgnoresOverrideOutsideTestMode(t *testing.T) { + for _, mode := range []map[string]string{ + {"GO_ENV": "", "NODE_ENV": ""}, + {"GO_ENV": "production", "NODE_ENV": "production"}, + {"GO_ENV": "development", "NODE_ENV": ""}, + } { + for k, v := range mode { + t.Setenv(k, v) + } + t.Setenv("VIP_TOKEN_OVERRIDE", "ambient-attacker-token") + + s := newTestStore() + if err := s.Save("keychain-token"); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Load() + if err != nil { + t.Fatalf("Load (%v): %v", mode, err) + } + if got != "keychain-token" { + t.Errorf("Load with %v = %q, want the stored credential", mode, got) + } + primary, err := s.LoadPrimary() + if err != nil { + t.Fatalf("LoadPrimary (%v): %v", mode, err) + } + if primary != "keychain-token" { + t.Errorf("LoadPrimary with %v = %q, want the stored credential", mode, primary) + } + } +} + +// TestLogoutRevokesTheSameTokenItDeletes reproduces the compounding half of +// 2.15. `vip logout` reads the bearer to revoke with LoadPrimary and then purges +// the keychain with Delete. While the override was honoured unconditionally, +// those were two DIFFERENT tokens: `VIP_TOKEN_OVERRIDE=x vip-next logout` +// revoked x server-side and deleted the user's real credential locally, leaving +// a live session nobody could log out of. +func TestLogoutRevokesTheSameTokenItDeletes(t *testing.T) { + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("VIP_TOKEN_OVERRIDE", "some-other-session") + + s := newTestStore() + if err := s.Save("the-credential-logout-will-delete"); err != nil { + t.Fatalf("Save: %v", err) + } + revoked, err := s.LoadPrimary() + if err != nil { + t.Fatalf("LoadPrimary: %v", err) + } + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if revoked != "the-credential-logout-will-delete" { + t.Errorf("logout would revoke %q but delete the stored credential", revoked) + } +} + +// TestStoreLoadIgnoresOverrideWithNoStoredToken is the other half: outside test +// mode the override must not manufacture a session out of nothing. +func TestStoreLoadIgnoresOverrideWithNoStoredToken(t *testing.T) { + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("VIP_TOKEN_OVERRIDE", "ambient-attacker-token") + + s := newTestStore() + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Errorf("Load = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadHonorsOverride(t *testing.T) { + t.Setenv("GO_ENV", "test") + s := newTestStore() + // Set a token in the keychain so we confirm the env var wins over it. + if err := s.Save("keychain-token"); err != nil { + t.Fatalf("Save: %v", err) + } + t.Setenv("VIP_TOKEN_OVERRIDE", "override-token") + got, err := s.Load() + if err != nil { + t.Fatalf("Load with override: %v", err) + } + if got != "override-token" { + t.Errorf("Load = %q, want %q", got, "override-token") + } +} + +func TestStoreLoadHonorsOverrideWhenKeychainEmpty(t *testing.T) { + t.Setenv("NODE_ENV", "test") + s := newTestStore() + // No token in keychain; env var should still provide a value. + t.Setenv("VIP_TOKEN_OVERRIDE", "env-only-token") + got, err := s.Load() + if err != nil { + t.Fatalf("Load with override (empty keychain): %v", err) + } + if got != "env-only-token" { + t.Errorf("Load = %q, want %q", got, "env-only-token") + } +} + +// Ensure the override is not active when the env var is unset (regression guard). +func TestStoreLoadNoOverrideWhenEnvUnset(t *testing.T) { + s := newTestStore() + os.Unsetenv("VIP_TOKEN_OVERRIDE") + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("expected ErrNoToken without override, got %v", err) + } +} + +func TestStoreDeleteClearsElevatedCache(t *testing.T) { + called := false + s := newTestStore() + s.OnDelete = func() error { + called = true + return nil + } + s.Save("x") + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if !called { + t.Error("OnDelete hook must fire after token removal") + } +} + +func TestStoreDeleteHookErrorIsNotFatal(t *testing.T) { + s := newTestStore() + s.OnDelete = func() error { return errors.New("hook boom") } + s.Save("x") + // Hook error must NOT mask successful token removal. Implementations can + // log via debug but Delete returns nil on hook failure (Node's logout + // proceeds even if tokenCache.clearAll throws). + if err := s.Delete(); err != nil { + t.Fatalf("Delete returned hook error; want nil so logout proceeds: %v", err) + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 000000000..ee478e343 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,117 @@ +// Package auth handles JWT decoding, validation, and the login flow. +// Token signature verification is intentionally NOT performed — the server +// validates on every request. This mirrors src/lib/token.ts. +package auth + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + json "encoding/json/v2" + "github.com/golang-jwt/jwt/v5" +) + +// Token holds the decoded, unverified claims from a VIP access token. +// Signature verification is skipped — the API server re-validates on every +// request, matching the behavior of the Node CLI (src/lib/token.ts). +type Token struct { + Raw string + ID int64 + IAT time.Time + Exp time.Time // zero value means "no exp claim" +} + +// ParseToken decodes the JWT claims without verifying the signature. +// Returns an error if raw is empty or the JWT is structurally invalid. +func ParseToken(raw string) (*Token, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("token is empty") + } + + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + claims := jwt.MapClaims{} + _, _, err := parser.ParseUnverified(raw, claims) + if err != nil { + return nil, fmt.Errorf("parse jwt: %w", err) + } + + tok := &Token{Raw: raw} + + if id, ok := claims["id"]; ok { + switch v := id.(type) { + case float64: + tok.ID = int64(v) + case int64: + tok.ID = v + case int: + tok.ID = int64(v) + } + } + + if iat, ok := claims["iat"]; ok { + tok.IAT = time.Unix(int64(toFloat(iat)), 0) + } + + if exp, ok := claims["exp"]; ok { + tok.Exp = time.Unix(int64(toFloat(exp)), 0) + } + + return tok, nil +} + +// Valid mirrors token.ts valid(): +// - false if no id +// - false if no iat +// - if no exp: true iff now > iat +// - if exp: true iff now > iat AND now < exp +func (t *Token) Valid() bool { + if t == nil || t.ID == 0 || t.IAT.IsZero() { + return false + } + now := time.Now() + if t.Exp.IsZero() { + return now.After(t.IAT) + } + return now.After(t.IAT) && now.Before(t.Exp) +} + +// Expired mirrors token.ts expired(): +// - false if no exp +// - true iff now > exp (strict greater-than, matching Node's `now > this.exp`) +func (t *Token) Expired() bool { + if t == nil || t.Exp.IsZero() { + return false + } + return time.Now().After(t.Exp) +} + +func toFloat(v any) float64 { + switch x := v.(type) { + case float64: + return x + case int64: + return float64(x) + case int: + return float64(x) + } + return 0 +} + +// encodeUnsignedJWT crafts an alg:none JWT from a claims map via base64url +// encoding. Used only by tests — not part of the production API. +func encodeUnsignedJWT(claims map[string]any) (string, error) { + headerJSON, err := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + if err != nil { + return "", fmt.Errorf("marshal header: %w", err) + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal claims: %w", err) + } + enc := base64.RawURLEncoding + return enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(claimsJSON) + ".", nil +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 000000000..16cfdcc6e --- /dev/null +++ b/internal/auth/token_test.go @@ -0,0 +1,90 @@ +package auth + +import ( + "testing" + "time" +) + +// makeJWT is a thin wrapper around encodeUnsignedJWT for test readability. +func makeJWT(t *testing.T, claims map[string]any) string { + t.Helper() + tok, err := encodeUnsignedJWT(claims) + if err != nil { + t.Fatalf("encodeUnsignedJWT: %v", err) + } + return tok +} + +func TestToken_Valid_ValidToken(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "id": float64(42), + "iat": float64(now.Add(-1 * time.Hour).Unix()), + "exp": float64(now.Add(1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if !tok.Valid() { + t.Error("Valid() should be true for a token with id, past iat, future exp") + } + if tok.Expired() { + t.Error("Expired() should be false for a token with future exp") + } + if tok.ID != 42 { + t.Errorf("ID = %d, want 42", tok.ID) + } +} + +func TestToken_Valid_ExpiredToken(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "id": float64(7), + "iat": float64(now.Add(-2 * time.Hour).Unix()), + "exp": float64(now.Add(-1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if tok.Valid() { + t.Error("Valid() should be false for an expired token") + } + if !tok.Expired() { + t.Error("Expired() should be true for a token whose exp is in the past") + } +} + +func TestToken_Valid_NoID(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "iat": float64(now.Add(-1 * time.Hour).Unix()), + "exp": float64(now.Add(1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if tok.Valid() { + t.Error("Valid() should be false when no id claim") + } +} + +func TestParseToken_Malformed(t *testing.T) { + _, err := ParseToken("this.is.not.a.jwt.at.all") + if err == nil { + t.Error("ParseToken should return an error for a malformed JWT") + } +} + +func TestParseToken_Empty(t *testing.T) { + _, err := ParseToken("") + if err == nil { + t.Error("ParseToken should return an error for an empty string") + } + _, err = ParseToken(" ") + if err == nil { + t.Error("ParseToken should return an error for a whitespace-only string") + } +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 000000000..a9655d677 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,194 @@ +// Package backup ports src/commands/backup-db.ts — the `vip backup db` +// runner: trigger a database backup unless one is already running, poll +// the db_backup job until its in-progress lock clears, and verify the +// terminal status. +package backup + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/poll" + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — DB_BACKUP_PROGRESS_POLL_INTERVAL (backup-db.ts:18). +const DefaultPollInterval = time.Second + +// DefaultPollTimeout is the ceiling backup-db.ts:198 inherits by calling +// pollUntil without a timeout: 6 hours (src/lib/utils.ts:18). +const DefaultPollTimeout = poll.DefaultTimeout + +// Step IDs (backup-db.ts:91). +const ( + StepPrepare = "prepare" + StepGenerate = "generate" +) + +// Job flattens the db_backup job fields the runner consumes +// (backup-db.ts:129-143). +type Job struct { + InProgressLock bool + Status string // progress.status + CompletedAt string + BackupName string // metadata[name=backupName].value; "Unknown" fallback is the caller's concern +} + +// Fetch retrieves the current db_backup job (nil when none exists). +type Fetch func(ctx context.Context) (*Job, error) + +// Create fires the TriggerDatabaseBackup mutation. +type Create func(ctx context.Context) error + +// RunOpts configures Run. Tracker must carry the prepare/generate steps. +type RunOpts struct { + Fetch Fetch + Create Create + Tracker *tui.ProgressTracker + Interval time.Duration + // Timeout caps the generate-phase poll. Zero means DefaultPollTimeout. + Timeout time.Duration + // Log mirrors BackupDBCommand.log (backup-db.ts:108); nil = silent. + Log func(msg string) + // FinalizeProgress flushes the completed tracker before the terminal + // success message is logged, matching BackupDBCommand.stopProgressTracker. + FinalizeProgress func() +} + +// Run ports BackupDBCommand.run (backup-db.ts:145). +func Run(ctx context.Context, opts RunOpts) error { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + timeout := opts.Timeout + if timeout == 0 { + timeout = DefaultPollTimeout + } + logf := opts.Log + if logf == nil { + logf = func(string) {} + } + + job, err := opts.Fetch(ctx) + if err != nil { + return fmt.Errorf("Couldn't create a new database backup job: %s", err.Error()) + } + + if job != nil && job.InProgressLock { + logf("Database backup already in progress...") + } else { + logf("Generating a new database backup...") + _ = opts.Tracker.StepRunning(StepPrepare) + if err := opts.Create(ctx); err != nil { + _ = opts.Tracker.StepFailed(StepPrepare) + if retryAfter, ok := RateLimitInfo(err); ok { + // backup-db.ts:172-181. Node's template literal ends with + // a stray tab before the closing backtick; normalized to a + // plain newline here. + return fmt.Errorf("A new database backup was not generated because a recently generated backup already exists.\nIf you would like to run the same command, you can retry in %s\nAlternatively, you can export the latest existing database backup by running: %s, right away.\nLearn more about limitations around generating database backups: https://docs.wpvip.com/databases/backups/limitations/\n", + FormatDuration(time.Now(), retryAfter), + color.GreenString("vip @app.env export sql")) + } + return fmt.Errorf("Couldn't create a new database backup job: %s", err.Error()) + } + } + + _ = opts.Tracker.StepSuccess(StepPrepare) // auto-promotes generate to running + + // pollUntil(loadBackupJob, 1s, isDone) — isDone = !job.inProgressLock + // (backup-db.ts:115,198). Node passes no timeout, so this runs under + // pollUntil's 6h ceiling; PollingTimeoutError falls into the same catch + // as a fetch failure and becomes "Failed to create new database backup: + // Polling timed out" (backup-db.ts:203-212). + if _, err := poll.Until(ctx, opts.Fetch, interval, + func(j *Job) bool { return j == nil || !j.InProgressLock }, timeout); err != nil { + _ = opts.Tracker.StepFailed(StepGenerate) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return fmt.Errorf("Failed to create new database backup: %s", err.Error()) + } + + _ = opts.Tracker.StepSuccess(StepGenerate) + + // Final verification re-fetch (backup-db.ts:218-224). + job, err = opts.Fetch(ctx) + if err != nil || job == nil || job.Status != "success" { + return errors.New("Failed to create a new database backup") + } + if opts.FinalizeProgress != nil { + opts.FinalizeProgress() + } + logf("New database backup created") + return nil +} + +// FormatDuration ports format.ts:242 formatDuration: "<N> day(s) <N> +// hour(s) <N> minute(s) <N> second(s)", omitting zero units, trailing +// space trimmed; "0 second" when under one second. +func FormatDuration(from, to time.Time) string { + duration := to.Sub(from) + if duration < time.Second { + return "0 second" + } + days := int(duration / (24 * time.Hour)) + hours := int(duration % (24 * time.Hour) / time.Hour) + minutes := int(duration % time.Hour / time.Minute) + seconds := int(duration % time.Minute / time.Second) + + var b strings.Builder + plural := func(n int, unit string) { + if n > 0 { + fmt.Fprintf(&b, "%d %s", n, unit) + if n > 1 { + b.WriteString("s") + } + b.WriteString(" ") + } + } + plural(days, "day") + plural(hours, "hour") + plural(minutes, "minute") + plural(seconds, "second") + return strings.TrimRight(b.String(), " ") +} + +// RateLimitInfo extracts the 429 rate-limit extensions from a genqlient +// error (gqlerror.List; backup-db.ts:162-166 reads +// extensions.errorHttpCode + extensions.retryAfter). ok=false when the +// error isn't a parseable rate limit. +func RateLimitInfo(err error) (retryAfter time.Time, ok bool) { + var list gqlerror.List + var single *gqlerror.Error + var ext map[string]interface{} + switch { + case errors.As(err, &list) && len(list) > 0: + ext = list[0].Extensions + case errors.As(err, &single): + ext = single.Extensions + default: + return time.Time{}, false + } + code, isFloat := ext["errorHttpCode"].(float64) + codeInt, isInt := ext["errorHttpCode"].(int) + if (!isFloat || int(code) != 429) && (!isInt || codeInt != 429) { + return time.Time{}, false + } + raw, _ := ext["retryAfter"].(string) + if raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339, time.RFC1123, "2006-01-02 15:04:05"} { + if t, perr := time.Parse(layout, raw); perr == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 000000000..eda23ac48 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,228 @@ +package backup + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/tui" +) + +func tracker() *tui.ProgressTracker { + return tui.NewProgressTracker([]tui.ProgressStep{ + {ID: StepPrepare, Name: "Preparing for backup generation"}, + {ID: StepGenerate, Name: "Generating backup"}, + }) +} + +func scriptedFetch(jobs []*Job, errs []error) Fetch { + i := 0 + return func(ctx context.Context) (*Job, error) { + idx := i + if i < len(jobs)-1 { + i++ + } + var err error + if idx < len(errs) { + err = errs[idx] + } + return jobs[idx], err + } +} + +func TestFormatDuration(t *testing.T) { + now := time.Now() + cases := []struct { + d time.Duration + want string + }{ + {500 * time.Millisecond, "0 second"}, + {time.Second, "1 second"}, + {65 * time.Second, "1 minute 5 seconds"}, + {49 * time.Hour, "2 days 1 hour"}, + {time.Hour + time.Minute + time.Second, "1 hour 1 minute 1 second"}, + } + for _, tc := range cases { + if got := FormatDuration(now, now.Add(tc.d)); got != tc.want { + t.Errorf("FormatDuration(+%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +func TestRunHappyPath(t *testing.T) { + var logs []string + created := 0 + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + nil, // initial load: no job + {InProgressLock: true}, + {InProgressLock: true}, + {InProgressLock: false, Status: "success", BackupName: "b1"}, + }, nil), + Create: func(ctx context.Context) error { created++; return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Log: func(m string) { logs = append(logs, m) }, + }) + if err != nil { + t.Fatal(err) + } + if created != 1 { + t.Errorf("Create called %d times", created) + } + joined := strings.Join(logs, "|") + if !strings.Contains(joined, "Generating a new database backup...") || + !strings.Contains(joined, "New database backup created") { + t.Errorf("logs = %v", logs) + } +} + +func TestRunAlreadyInProgress(t *testing.T) { + var logs []string + created := 0 + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + {InProgressLock: true}, + {InProgressLock: false, Status: "success"}, + }, nil), + Create: func(ctx context.Context) error { created++; return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Log: func(m string) { logs = append(logs, m) }, + }) + if err != nil { + t.Fatal(err) + } + if created != 0 { + t.Error("Create must not fire when a backup is already running (backup-db.ts:150)") + } + if !strings.Contains(strings.Join(logs, "|"), "Database backup already in progress...") { + t.Errorf("logs = %v", logs) + } +} + +func TestRunFinalStatusNotSuccess(t *testing.T) { + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + nil, + {InProgressLock: false, Status: "failed"}, + }, nil), + Create: func(ctx context.Context) error { return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Failed to create a new database backup" { + t.Errorf("err = %v", err) + } +} + +func TestRunCreateFails(t *testing.T) { + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{nil}, nil), + Create: func(ctx context.Context) error { return errors.New("boom") }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Couldn't create a new database backup job: boom" { + t.Errorf("err = %v", err) + } +} + +func TestRunCreateRateLimited(t *testing.T) { + retryAt := time.Now().Add(90 * time.Minute).Format(time.RFC3339) + rlErr := gqlerror.List{&gqlerror.Error{ + Message: "rate limited", + Extensions: map[string]interface{}{ + "errorHttpCode": float64(429), + "retryAfter": retryAt, + }, + }} + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{nil}, nil), + Create: func(ctx context.Context) error { return rlErr }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || + !strings.Contains(err.Error(), "A new database backup was not generated because a recently generated backup already exists.") || + !strings.Contains(err.Error(), "vip @app.env export sql") || + !strings.Contains(err.Error(), "https://docs.wpvip.com/databases/backups/limitations/") { + t.Errorf("err = %v", err) + } + if !strings.Contains(err.Error(), "hour") && !strings.Contains(err.Error(), "minute") { + t.Errorf("rate-limit message missing duration: %v", err) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling pins the ceiling `vip backup db` +// inherits from Node: backup-db.ts:198 calls pollUntil with no explicit +// timeout, so it gets the 6h default from utils.ts:18. +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } +} + +// TestRunStopsWhenBackupNeverCompletes is the regression test for the +// unbounded generate-phase poll loop: a job whose inProgressLock never +// clears used to spin forever (in CI: a wedged run instead of a failure). +// Node's pollUntil gives up at the ceiling and the surrounding catch turns +// PollingTimeoutError into `Failed to create new database backup: Polling +// timed out` (backup-db.ts:203-212). +func TestRunStopsWhenBackupNeverCompletes(t *testing.T) { + fetches := 0 + done := make(chan error, 1) + go func() { + done <- Run(context.Background(), RunOpts{ + Fetch: func(ctx context.Context) (*Job, error) { + fetches++ + return &Job{InProgressLock: true}, nil + }, + Create: func(ctx context.Context) error { return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }) + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Failed to create new database backup: Polling timed out" { + t.Errorf("err = %v, want %q", err, + "Failed to create new database backup: Polling timed out") + } + if fetches < 2 { + t.Errorf("fetches = %d, want the loop to have actually polled", fetches) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the generate-phase poll loop is unbounded") + } +} + +func TestRateLimitInfo(t *testing.T) { + retryAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + list := gqlerror.List{&gqlerror.Error{ + Message: "x", + Extensions: map[string]interface{}{ + "errorHttpCode": float64(429), + "retryAfter": retryAt.Format(time.RFC3339), + }, + }} + got, ok := RateLimitInfo(list) + if !ok || !got.Equal(retryAt) { + t.Errorf("got %v ok=%v", got, ok) + } + + if _, ok := RateLimitInfo(errors.New("plain")); ok { + t.Error("plain error must not parse as rate limit") + } + if _, ok := RateLimitInfo(gqlerror.List{&gqlerror.Error{ + Message: "x", Extensions: map[string]interface{}{"errorHttpCode": float64(500)}, + }}); ok { + t.Error("non-429 must not parse as rate limit") + } +} diff --git a/internal/cachepurge/cachepurge.go b/internal/cachepurge/cachepurge.go new file mode 100644 index 000000000..b14bb881b --- /dev/null +++ b/internal/cachepurge/cachepurge.go @@ -0,0 +1,38 @@ +// Package cachepurge wraps the PurgePageCache mutation. +// +// Node parity: src/lib/api/cache-purge.ts. The server canonicalizes the +// supplied URLs (e.g. host-normalization) and returns the canonical list on +// the response payload, so callers should use the returned slice for any +// downstream "Purged URL: ..." output rather than echoing the input. +package cachepurge + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// Purge invokes the purgePageCache mutation against the given environment +// and returns the server-canonicalized URL list. The returned slice MAY +// differ from urls (server normalizes hosts/casing); callers should rely +// on it when echoing results to the user. +func Purge(ctx context.Context, c graphql.Client, appID, envID int64, urls []string) ([]string, error) { + input := &gql.PurgePageCacheInput{ + AppId: appID, + EnvironmentId: envID, + Urls: urls, + } + resp, err := gql.PurgePageCache(ctx, c, input) + if err != nil { + return nil, err + } + if resp == nil || resp.PurgePageCache == nil { + // Defensive: schema marks PurgePageCachePayload non-null, but a + // pathological server response could omit it. Return empty so the + // caller prints nothing rather than panicking. + return nil, nil + } + return resp.PurgePageCache.Urls, nil +} diff --git a/internal/cachepurge/cachepurge_test.go b/internal/cachepurge/cachepurge_test.go new file mode 100644 index 000000000..102cc1f44 --- /dev/null +++ b/internal/cachepurge/cachepurge_test.go @@ -0,0 +1,92 @@ +package cachepurge + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// TestPurgeSendsMutationAndReturnsCanonicalURLs verifies (a) the wire +// request carries the PurgePageCache operation + the expected input shape +// and (b) the function returns the server-canonicalized URL slice rather +// than echoing the input. +func TestPurgeSendsMutationAndReturnsCanonicalURLs(t *testing.T) { + var lastBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + lastBody = string(b) + w.Header().Set("Content-Type", "application/json") + // Return DIFFERENT URLs than the input — server canonicalization. + _, _ = w.Write([]byte(`{"data":{"purgePageCache":{"success":true,"urls":["https://canonical.example.com/a","https://canonical.example.com/b"]}}}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + in := []string{"https://example.com/a", "https://example.com/b"} + out, err := Purge(context.Background(), c, 42, 7, in) + if err != nil { + t.Fatalf("Purge: %v", err) + } + + if len(out) != 2 || out[0] != "https://canonical.example.com/a" || out[1] != "https://canonical.example.com/b" { + t.Errorf("Purge returned %v, want canonical server URLs", out) + } + + if !strings.Contains(lastBody, `"operationName":"PurgePageCache"`) { + t.Errorf("request must use PurgePageCache op; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"appId":42`) { + t.Errorf("input.appId missing; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"environmentId":7`) { + t.Errorf("input.environmentId missing; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"urls":["https://example.com/a","https://example.com/b"]`) { + t.Errorf("input.urls missing or wrong shape; body=%s", lastBody) + } +} + +// TestPurgeNilPayloadReturnsEmpty pins the nil-guard in Purge: a server +// response of {"data":{"purgePageCache":null}} must produce (nil, nil) +// instead of panicking on the .Urls dereference. Prevents the guard from +// being silently dropped by a future "simplification". +func TestPurgeNilPayloadReturnsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"purgePageCache":null}}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + out, err := Purge(context.Background(), c, 1, 2, []string{"https://example.com/"}) + if err != nil { + t.Fatalf("unexpected error on null payload: %v", err) + } + if out != nil { + t.Errorf("expected nil slice for null payload; got %v", out) + } +} + +// TestPurgeServerError propagates the underlying GraphQL error so the +// command handler can wrap it with the "Failed to purge URL(s)..." prefix. +func TestPurgeServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"boom"}]}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + out, err := Purge(context.Background(), c, 1, 2, []string{"https://example.com/"}) + if err == nil { + t.Fatalf("expected error from server; got out=%v", out) + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error must propagate server message; got %q", err.Error()) + } +} diff --git a/internal/customdeploy/archive.go b/internal/customdeploy/archive.go new file mode 100644 index 000000000..92ea80439 --- /dev/null +++ b/internal/customdeploy/archive.go @@ -0,0 +1,205 @@ +package customdeploy + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "regexp" + "strings" +) + +// Error messages — validations/custom-deploy.ts:14. +const ( + errMissingThemes = "Missing `themes` directory from root folder." + errSymlink = "Symlink detected: " + errSingleRootDir = "The compressed file must contain a single root directory." +) + +const macosxDir = "__MACOSX" + +// symlinkIgnoreRE — validations/custom-deploy.ts:22. +var symlinkIgnoreRE = regexp.MustCompile(`/node_modules/[^/]+/\.bin/`) + +// Per-entry name patterns — validations/custom-deploy.ts:67. +var ( + invalidDirCharsRE = regexp.MustCompile(`[!:*?"<>|']|^\.\..*$`) + invalidFileCharsRE = regexp.MustCompile(`[!/:*?"<>|']|^\.\..*$`) +) + +// validateName ports validateName (validations/custom-deploy.ts:62). +func validateName(name string, isDirectory bool) error { + if strings.HasPrefix(name, "._") { + return nil + } + re := invalidFileCharsRE + chars := `[!/:*?"<>|'/^..]+` + if isDirectory { + re = invalidDirCharsRE + chars = `[!:*?"<>|'/^..]+` + } + if re.MatchString(name) { + return fmt.Errorf("Filename %s contains disallowed characters: %s", name, chars) + } + return nil +} + +// ValidateZipFile ports validateZipFile (validations/custom-deploy.ts:143). +func ValidateZipFile(filePath string) error { + zr, err := zip.OpenReader(filePath) + if err != nil { + return fmt.Errorf("Error reading file: %s", err.Error()) + } + defer zr.Close() + + var rootDirs []string + for _, f := range zr.File { + name := f.Name + if !strings.HasSuffix(name, "/") || strings.HasPrefix(name, macosxDir) { + continue + } + if strings.Count(name, "/") == 1 { + rootDirs = append(rootDirs, name) + } + } + if len(rootDirs) != 1 { + return errors.New(errSingleRootDir) + } + rootFolder := rootDirs[0] + + // themes/ under the root (validations/custom-deploy.ts:124). + hasThemes := false + requiredPrefix := path.Join(rootFolder, "themes") + "/" + for _, f := range zr.File { + name := strings.ReplaceAll(f.Name, `\`, "/") + if strings.HasSuffix(f.Name, "/") && strings.HasPrefix(name, requiredPrefix) { + hasThemes = true + break + } + } + if !hasThemes { + return errors.New(errMissingThemes) + } + + for _, f := range zr.File { + if strings.HasPrefix(f.Name, macosxDir) { + continue + } + isDir := strings.HasSuffix(f.Name, "/") + name := f.Name + if !isDir { + name = path.Base(f.Name) + } + if err := validateName(name, isDir); err != nil { + return err + } + // Symlink detection: Go's zip reader surfaces the Unix mode bits + // from the external attributes (the ts:97 case). The DOS-attr + // variant (ts:92) is not reachable through archive/zip — noted as + // an intentional gap. + if symlinkIgnoreRE.MatchString(f.Name) { + continue + } + if f.Mode()&os.ModeSymlink != 0 { + return errors.New(errSymlink + f.Name) + } + } + return nil +} + +// ValidateTarFile ports validateTarFile (validations/custom-deploy.ts:220). +// Handles gzipped (.tar.gz/.tgz) and plain tar input. +func ValidateTarFile(filePath string) error { + f, err := os.Open(filePath) // #nosec G304 -- user-supplied CLI path + if err != nil { + return err + } + defer f.Close() + + var r io.Reader = f + magic := make([]byte, 2) + if _, err := io.ReadFull(f, magic); err == nil && magic[0] == 0x1f && magic[1] == 0x8b { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + zr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer zr.Close() + r = zr + } else { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + } + + tr := tar.NewReader(r) + rootFolder := "" + type tarEntry struct { + path string + isDir bool + } + var entries []tarEntry + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + name := hdr.Name + if strings.HasPrefix(name, macosxDir) { + continue + } + var isDir, isSymlink bool + switch hdr.Typeflag { + case tar.TypeDir: + isDir = true + case tar.TypeReg: + case tar.TypeSymlink, tar.TypeLink: + isSymlink = hdr.Typeflag == tar.TypeSymlink + if !isSymlink { + continue + } + default: + continue + } + + isRootFolder := isDir && strings.HasSuffix(name, "/") && strings.Count(name, "/") == 1 + if isRootFolder { + if rootFolder == "" { + rootFolder = name + } else if rootFolder != name { + return errors.New(errSingleRootDir) + } + } + + // validateTarEntry (ts:191): symlink check first, then name. + if isSymlink && !symlinkIgnoreRE.MatchString(name) { + return errors.New(errSymlink + name) + } + if err := validateName(path.Base(strings.TrimSuffix(name, "/")), isDir); err != nil { + return err + } + entries = append(entries, tarEntry{path: name, isDir: isDir}) + } + + if rootFolder == "" { + return errors.New(errSingleRootDir) + } + + themesPath := path.Join(rootFolder, "themes") + "/" + for _, e := range entries { + if e.isDir && e.path == themesPath { + return nil + } + } + return errors.New(errMissingThemes) +} diff --git a/internal/customdeploy/archive_test.go b/internal/customdeploy/archive_test.go new file mode 100644 index 000000000..d22308b26 --- /dev/null +++ b/internal/customdeploy/archive_test.go @@ -0,0 +1,174 @@ +package customdeploy + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildZip writes a zip with the given entries; names ending in "/" are +// directories; symlinkTargets maps entry name -> target. +func buildZip(t *testing.T, entries []string, symlinks map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "app.zip") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for _, name := range entries { + hdr := &zip.FileHeader{Name: name} + if strings.HasSuffix(name, "/") { + hdr.SetMode(os.ModeDir | 0o755) + } else { + hdr.SetMode(0o644) + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(name, "/") { + _, _ = w.Write([]byte("x")) + } + } + for name, target := range symlinks { + hdr := &zip.FileHeader{Name: name} + hdr.SetMode(os.ModeSymlink | 0o777) + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(target)) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +// buildTarGz writes a .tar.gz with dirs (trailing /), files, and symlinks. +func buildTarGz(t *testing.T, dirs, files []string, symlinks map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "app.tar.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + tw := tar.NewWriter(zw) + for _, d := range dirs { + if err := tw.WriteHeader(&tar.Header{Name: d, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + } + for _, fl := range files { + if err := tw.WriteHeader(&tar.Header{Name: fl, Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte("x")) + } + for name, target := range symlinks { + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeSymlink, Linkname: target, Mode: 0o777}); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func TestValidateZipFileClean(t *testing.T) { + p := buildZip(t, []string{ + "app/", "app/themes/", "app/themes/style.css", "app/plugins/", "app/plugins/x.php", + "__MACOSX/", "__MACOSX/junk!|.txt", + }, nil) + if err := ValidateZipFile(p); err != nil { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileTwoRoots(t *testing.T) { + p := buildZip(t, []string{"a/", "a/themes/", "b/", "b/x.txt"}, nil) + if err := ValidateZipFile(p); err == nil || err.Error() != errSingleRootDir { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileMissingThemes(t *testing.T) { + p := buildZip(t, []string{"app/", "app/plugins/"}, nil) + if err := ValidateZipFile(p); err == nil || err.Error() != errMissingThemes { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileSymlink(t *testing.T) { + p := buildZip(t, []string{"app/", "app/themes/"}, + map[string]string{"app/evil-link": "/etc/passwd"}) + if err := ValidateZipFile(p); err == nil || !strings.Contains(err.Error(), "Symlink detected: app/evil-link") { + t.Errorf("err = %v", err) + } + // node_modules/.bin symlinks are exempt (validations/custom-deploy.ts:22). + p = buildZip(t, []string{"app/", "app/themes/"}, + map[string]string{"app/node_modules/pkg/.bin/tool": "../lib/tool.js"}) + if err := ValidateZipFile(p); err != nil { + t.Errorf("exempt symlink rejected: %v", err) + } +} + +func TestValidateZipFileBadChars(t *testing.T) { + p := buildZip(t, []string{"app/", "app/themes/", "app/bad?.txt"}, nil) + if err := ValidateZipFile(p); err == nil || !strings.Contains(err.Error(), "contains disallowed characters") { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileClean(t *testing.T) { + p := buildTarGz(t, + []string{"app/", "app/themes/"}, + []string{"app/themes/style.css"}, + nil) + if err := ValidateTarFile(p); err != nil { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileMissingThemes(t *testing.T) { + p := buildTarGz(t, []string{"app/"}, []string{"app/x.php"}, nil) + if err := ValidateTarFile(p); err == nil || err.Error() != errMissingThemes { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileTwoRoots(t *testing.T) { + p := buildTarGz(t, []string{"a/", "a/themes/", "b/"}, nil, nil) + if err := ValidateTarFile(p); err == nil || err.Error() != errSingleRootDir { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileSymlink(t *testing.T) { + p := buildTarGz(t, []string{"app/", "app/themes/"}, nil, + map[string]string{"app/evil": "/etc/passwd"}) + if err := ValidateTarFile(p); err == nil || !strings.Contains(err.Error(), "Symlink detected: app/evil") { + t.Errorf("err = %v", err) + } + p = buildTarGz(t, []string{"app/", "app/themes/"}, nil, + map[string]string{"app/node_modules/pkg/.bin/tool": "x"}) + if err := ValidateTarFile(p); err != nil { + t.Errorf("exempt symlink rejected: %v", err) + } +} diff --git a/internal/customdeploy/customdeploy.go b/internal/customdeploy/customdeploy.go new file mode 100644 index 000000000..31b4e2b56 --- /dev/null +++ b/internal/customdeploy/customdeploy.go @@ -0,0 +1,91 @@ +// Package customdeploy ports src/lib/custom-deploy/custom-deploy.ts and +// src/lib/validations/custom-deploy.ts — the gates and archive checks +// behind `vip app deploy` (+ `validate`). +package customdeploy + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Automattic/vip/internal/upload" +) + +// DeployMaxFileSize — DEPLOY_MAX_FILE_SIZE = 4 GiB (custom-deploy.ts:11). +const DeployMaxFileSize = int64(4) * 1024 * 1024 * 1024 + +// DeployInfo mirrors CustomDeployInfo (custom-deploy.ts:14). +type DeployInfo struct { + AppID int64 + EnvID int64 + EnvType string + EnvUniqueLabel string + PrimaryDomainName string + Launched bool +} + +// validFilenameRE — validations/custom-deploy.ts:49 (same charset as +// import sql, different message). +var validFilenameRE = regexp.MustCompile(`(?i)^[a-z0-9\-_.]+$`) + +// ValidateDeployFilename ports validateFilename (validations/custom-deploy.ts:48). +func ValidateDeployFilename(filename string) error { + if !validFilenameRE.MatchString(filename) { + return fmt.Errorf("Filename %s contains disallowed characters: [0-9,a-z,A-Z,-,_,.]", filename) + } + return nil +} + +// ValidateDeployFileExt ports validateDeployFileExt +// (validations/custom-deploy.ts:31): .zip, .tar.gz, or .tgz. +func ValidateDeployFileExt(filename string) error { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == ".gz" && strings.ToLower(filepath.Ext(strings.TrimSuffix(filename, filepath.Ext(filename)))) == ".tar" { + ext = ".tar.gz" + } + if ext != ".zip" && ext != ".tar.gz" && ext != ".tgz" { + return errors.New("Invalid file extension. Please provide a .zip, .tar.gz, or a .tgz file.") + } + return nil +} + +// ValidateFile ports validateFile (custom-deploy.ts:74): the gate +// sequence ahead of upload. maxSize is injectable for tests; 0 uses the +// 4 GiB production limit. +func ValidateFile(meta upload.FileMeta, maxSize int64) error { + if maxSize == 0 { + maxSize = DeployMaxFileSize + } + + fi, statErr := os.Stat(meta.FileName) + if statErr != nil { + return fmt.Errorf("Unable to access file %s", meta.FileName) + } + if !meta.IsCompressed { + return fmt.Errorf("Please compress file %s before uploading.", meta.FileName) + } + if err := ValidateDeployFilename(meta.BaseName); err != nil { + return err + } + if err := ValidateDeployFileExt(meta.FileName); err != nil { + return err + } + if f, err := os.Open(meta.FileName); err != nil { // #nosec G304 -- checkFileAccess parity + return fmt.Errorf("File '%s' does not exist or is not readable.", meta.FileName) + } else { + f.Close() + } + if fi.IsDir() { + return fmt.Errorf("Path '%s' is not a file.", meta.FileName) + } + if fi.Size() == 0 { + return fmt.Errorf("File '%s' is empty.", meta.FileName) + } + if fi.Size() > maxSize { + return fmt.Errorf("The deploy file size (%d bytes) exceeds the limit (%d bytes).", fi.Size(), maxSize) + } + return nil +} diff --git a/internal/customdeploy/customdeploy_test.go b/internal/customdeploy/customdeploy_test.go new file mode 100644 index 000000000..9eb81a645 --- /dev/null +++ b/internal/customdeploy/customdeploy_test.go @@ -0,0 +1,82 @@ +package customdeploy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/upload" +) + +func metaFor(t *testing.T, name string, content []byte) upload.FileMeta { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, err := upload.GetFileMeta(p) + if err != nil { + t.Fatal(err) + } + return meta +} + +// gzMagic makes content sniff as gzip so IsCompressed is true. +var gzMagic = []byte{0x1f, 0x8b, 0x08, 0x00, 0x01, 0x02, 0x03} + +func TestValidateDeployFileExt(t *testing.T) { + for name, ok := range map[string]bool{ + "app.zip": true, "app.tar.gz": true, "app.tgz": true, "APP.TGZ": true, + "app.sql": false, "app.gz": false, "app.tar": false, + } { + err := ValidateDeployFileExt(name) + if ok && err != nil { + t.Errorf("%s: unexpected err %v", name, err) + } + if !ok && (err == nil || !strings.Contains(err.Error(), "Invalid file extension. Please provide a .zip, .tar.gz, or a .tgz file.")) { + t.Errorf("%s: err = %v", name, err) + } + } +} + +func TestValidateDeployFilename(t *testing.T) { + if err := ValidateDeployFilename("release-1.2.3.tgz"); err != nil { + t.Errorf("err = %v", err) + } + err := ValidateDeployFilename("bad name!.zip") + if err == nil || !strings.Contains(err.Error(), "Filename bad name!.zip contains disallowed characters: [0-9,a-z,A-Z,-,_,.]") { + t.Errorf("err = %v", err) + } +} + +func TestValidateFileGates(t *testing.T) { + uncompressed := metaFor(t, "app.tgz", []byte("plain text, not gzip")) + if err := ValidateFile(uncompressed, 0); err == nil || + !strings.Contains(err.Error(), "Please compress file") { + t.Errorf("err = %v", err) + } + + good := metaFor(t, "app.tgz", gzMagic) + if err := ValidateFile(good, 0); err != nil { + t.Errorf("err = %v", err) + } + + tooBig := metaFor(t, "app.tgz", append(gzMagic, make([]byte, 100)...)) + if err := ValidateFile(tooBig, 10); err == nil || + !strings.Contains(err.Error(), "exceeds the limit (10 bytes).") { + t.Errorf("err = %v", err) + } + + missing := upload.FileMeta{FileName: filepath.Join(t.TempDir(), "nope.tgz"), BaseName: "nope.tgz", IsCompressed: true} + if err := ValidateFile(missing, 0); err == nil || + !strings.Contains(err.Error(), "Unable to access file") { + t.Errorf("err = %v", err) + } + + badExt := metaFor(t, "app.gz", gzMagic) + if err := ValidateFile(badExt, 0); err == nil || + !strings.Contains(err.Error(), "Invalid file extension.") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/defensivemode/api.go b/internal/defensivemode/api.go new file mode 100644 index 000000000..d930a00bc --- /dev/null +++ b/internal/defensivemode/api.go @@ -0,0 +1,89 @@ +// Package defensivemode is the M3+M4 test surface for the rechallenge +// middleware. Two GraphQL mutations: UpdateDefensiveModeStatus and +// UpdateDefensiveModeConfig. Both require step-up auth on production +// environments — the rechallenge middleware in internal/gql handles that +// transparently. M4: ports the raw HTTP POST to genqlient. +package defensivemode + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// UpdateStatusInput is the Go side of AppEnvironmentDefensiveModeUpdateStatusInput. +// The Node wire format uses `id`/`environmentId`/`enabled` (NOT appId/envId); +// genqlient handles the on-the-wire field names from the schema. +type UpdateStatusInput struct { + AppID int64 + EnvID int64 + Enabled bool +} + +type UpdateConfigInput struct { + AppID int64 + EnvID int64 + Enabled bool + ChallengeType int + ConnectionThresholdAbsolute *int + ConnectionThresholdPercentage *int +} + +type MutationResult struct { + Success bool + Message string +} + +func UpdateDefensiveModeStatus(ctx context.Context, client graphql.Client, in UpdateStatusInput) (*MutationResult, error) { + input := &gql.AppEnvironmentDefensiveModeUpdateStatusInput{ + Enabled: in.Enabled, + EnvironmentId: in.EnvID, + Id: in.AppID, + } + resp, err := gql.UpdateDefensiveModeStatus(ctx, client, input) + if err != nil { + return nil, err + } + if resp == nil || resp.UpdateDefensiveModeStatus == nil { + return nil, errMissingPayload("updateDefensiveModeStatus") + } + return &MutationResult{ + Success: resp.UpdateDefensiveModeStatus.Success, + Message: resp.UpdateDefensiveModeStatus.Message, + }, nil +} + +func UpdateDefensiveModeConfig(ctx context.Context, client graphql.Client, in UpdateConfigInput) (*MutationResult, error) { + input := &gql.AppEnvironmentDefensiveModeConfigInput{ + Enabled: in.Enabled, + EnvironmentId: in.EnvID, + Id: in.AppID, + ChallengeType: int64(in.ChallengeType), + } + if in.ConnectionThresholdAbsolute != nil { + v := int64(*in.ConnectionThresholdAbsolute) + input.ConnectionThresholdAbsolute = &v + } + if in.ConnectionThresholdPercentage != nil { + v := int64(*in.ConnectionThresholdPercentage) + input.ConnectionThresholdPercentage = &v + } + resp, err := gql.UpdateDefensiveModeConfig(ctx, client, input) + if err != nil { + return nil, err + } + if resp == nil || resp.UpdateDefensiveModeConfig == nil { + return nil, errMissingPayload("updateDefensiveModeConfig") + } + return &MutationResult{ + Success: resp.UpdateDefensiveModeConfig.Success, + Message: resp.UpdateDefensiveModeConfig.Message, + }, nil +} + +func errMissingPayload(field string) error { + return fmt.Errorf("%s response missing payload; the API may have rejected the request", field) +} diff --git a/internal/defensivemode/api_test.go b/internal/defensivemode/api_test.go new file mode 100644 index 000000000..ca338768e --- /dev/null +++ b/internal/defensivemode/api_test.go @@ -0,0 +1,122 @@ +package defensivemode + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +func newGQLClient(srv *httptest.Server) graphql.Client { + return graphql.NewClient(srv.URL+"/graphql", srv.Client()) +} + +func TestUpdateDefensiveModeStatusBuildsCorrectRequest(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + result, err := UpdateDefensiveModeStatus(context.Background(), c, UpdateStatusInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeStatus: %v", err) + } + if !result.Success || result.Message != "ok" { + t.Errorf("result = %+v", result) + } + if !strings.Contains(gotBody, `"operationName":"UpdateDefensiveModeStatus"`) { + t.Errorf("operationName missing: %s", gotBody) + } + // Verify the wire shape uses id / environmentId / enabled keys. + for _, want := range []string{`"id":42`, `"environmentId":7`, `"enabled":true`} { + if !strings.Contains(gotBody, want) { + t.Errorf("expected %q in body; got %s", want, gotBody) + } + } +} + +func TestUpdateDefensiveModeConfigOmitsUnsetThresholds(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeConfig":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + _, err := UpdateDefensiveModeConfig(context.Background(), c, UpdateConfigInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + ChallengeType: 1, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeConfig: %v", err) + } + // Optional thresholds: when nil on the Go side, they should serialize as + // null on the wire (genqlient pointer optionals are encoded that way), + // not omitted entirely. The schema accepts null for these fields. + // The important property: don't send a NON-null integer for a threshold + // the user didn't set. Look for the substring "5000" / "80" which would + // indicate a leaked value. + if strings.Contains(gotBody, "5000") { + t.Errorf("unset absolute threshold leaked: %s", gotBody) + } + if strings.Contains(gotBody, ",80,") || strings.Contains(gotBody, ":80}") { + t.Errorf("unset percentage threshold leaked: %s", gotBody) + } +} + +func TestUpdateDefensiveModeConfigIncludesSetThresholds(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeConfig":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + abs := 5000 + pct := 80 + _, err := UpdateDefensiveModeConfig(context.Background(), c, UpdateConfigInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + ChallengeType: 2, + ConnectionThresholdAbsolute: &abs, + ConnectionThresholdPercentage: &pct, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeConfig: %v", err) + } + if !strings.Contains(gotBody, `"connectionThresholdAbsolute":5000`) { + t.Errorf("absolute threshold missing: %s", gotBody) + } + if !strings.Contains(gotBody, `"connectionThresholdPercentage":80`) { + t.Errorf("percentage threshold missing: %s", gotBody) + } +} + +func TestUpdateDefensiveModeReturnsErrorWhenNoPayload(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":null}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + _, err := UpdateDefensiveModeStatus(context.Background(), c, UpdateStatusInput{AppID: 1, EnvID: 1, Enabled: true}) + if err == nil { + t.Error("expected error when payload is null") + } +} diff --git a/internal/devenv/adapters.go b/internal/devenv/adapters.go new file mode 100644 index 000000000..ffc240066 --- /dev/null +++ b/internal/devenv/adapters.go @@ -0,0 +1,168 @@ +package devenv + +import ( + "context" + "runtime" + "strings" + "time" + + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/lifecycle" + "github.com/Automattic/vip/internal/devenv/proxy" + "github.com/Automattic/vip/internal/httpproxy" +) + +// dockerAdapter satisfies lifecycle.Docker using the real *dockercli.Runner. +type dockerAdapter struct{ r *dockercli.Runner } + +func (a dockerAdapter) Compose(ctx context.Context, project string, args ...string) error { + return a.r.Compose(ctx, project, args...) +} +func (a dockerAdapter) ComposePS(ctx context.Context, project string) ([]lifecycle.ServiceState, error) { + raw, err := a.r.ComposePS(ctx, project) + if err != nil { + return nil, err + } + out := make([]lifecycle.ServiceState, len(raw)) + for i, s := range raw { + out[i] = lifecycle.ServiceState{Service: s.Service, State: s.State, ExitCode: s.ExitCode} + } + return out, nil +} +func (a dockerAdapter) ListVolumes(ctx context.Context) ([]string, error) { + b, err := a.r.DockerOut(ctx, "volume", "ls", "--format", "{{.Name}}") + if err != nil { + return nil, err + } + return parseVolumeLines(b), nil +} + +// parseVolumeLines splits `docker volume ls --format {{.Name}}` output into names, +// dropping the trailing newline and any blank lines. +func parseVolumeLines(b []byte) []string { + var names []string + for _, line := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + if line != "" { + names = append(names, line) + } + } + return names +} + +func (a dockerAdapter) ListContainers(ctx context.Context, filters ...string) ([]lifecycle.Container, error) { + args := []string{"ps", "-a", "--format", "{{.ID}}\t{{.Names}}"} + for _, f := range filters { + args = append(args, "--filter", f) + } + b, err := a.r.DockerOut(ctx, args...) + if err != nil { + return nil, err + } + return parseContainerLines(b), nil +} + +// parseContainerLines splits `docker ps --format {{.ID}}\t{{.Names}}` output into +// Containers, dropping the trailing newline and any blank lines. +func parseContainerLines(b []byte) []lifecycle.Container { + var out []lifecycle.Container + for _, line := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 2) + c := lifecycle.Container{ID: parts[0]} + if len(parts) > 1 { + c.Name = parts[1] + } + out = append(out, c) + } + return out +} + +// proxyAdapter binds the proxy package funcs to the lifecycle.Proxy interface, +// carrying the runner so each call uses the real DockerRunner. +type proxyAdapter struct{ r *dockercli.Runner } + +func (a proxyAdapter) Ensure(ctx context.Context, o proxy.EnsureOptions) (proxy.Ports, error) { + return proxy.Ensure(ctx, a.r, o) +} +func (a proxyAdapter) EnsureCA(ctx context.Context) error { return proxy.EnsureCA(ctx, a.r) } +func (a proxyAdapter) EnsureCert(ctx context.Context, req proxy.CertRequest) error { + return proxy.EnsureCert(ctx, a.r, req) +} +func (a proxyAdapter) ExtractCA(ctx context.Context, dest string) (string, error) { + return proxy.ExtractCA(ctx, a.r, dest) +} +func (a proxyAdapter) Cleanup(ctx context.Context) error { return proxy.Cleanup(ctx, a.r) } +func (a proxyAdapter) RemoveOrphan(ctx context.Context) error { return proxy.RemoveOrphan(ctx, a.r) } +func (a proxyAdapter) ForceRemove(ctx context.Context) error { return proxy.ForceRemove(ctx, a.r) } + +// elevatorAdapter / httpProber are trivial real impls. +type elevatorAdapter struct{} + +func (elevatorAdapter) Apply(plan hostops.PrivilegedPlan) error { return hostops.Apply(plan) } +func (elevatorAdapter) CATrusted(caPath string) bool { + return hostops.CATrusted(runtime.GOOS, caPath) +} +func (elevatorAdapter) HostsPresent(hosts []string) bool { return hostops.HostsPresent(hosts) } + +type httpProber struct{} + +// Probe fetches the environment's own front-end URL, e.g. +// https://<slug>.vipdev.site/, which /etc/hosts maps to 127.0.0.1 on THIS +// machine. It must never be proxied: httpproxy.ProxyURL honours VIP_PROXY +// unconditionally and exempts no loopback (matching Node's proxy-from-env), so +// the policy client would hand this hostname to the developer's SOCKS proxy to +// resolve and dial on the proxy's side, where the containers do not exist. Node +// does not proxy it either — Lando's health check is internal, and the only +// dev-environment request Node routes through createProxyAgent is the WordPress +// version manifest (dev-environment-core.ts:1044). +func (httpProber) Probe(url string) (int, error) { + c := httpproxy.DirectClientWithTimeout(5 * time.Second) + resp, err := c.Get(url) + if err != nil { + return 0, err + } + defer resp.Body.Close() + return resp.StatusCode, nil +} + +// subsiteAdapter lists multisite subsite domains via `wp site list` on the php +// container. Satisfies lifecycle.SubsiteLister. +type subsiteAdapter struct{ r *dockercli.Runner } + +func (a subsiteAdapter) ListSubsiteDomains(ctx context.Context, project, service string) ([]string, error) { + out, err := a.r.ComposeOut(ctx, project, + "exec", "-T", "-u", "www-data", service, + "wp", "--allow-root", "site", "list", "--fields=domain", "--format=csv") + if err != nil { + return nil, err + } + return parseSiteListCSV(out), nil +} + +// parseSiteListCSV extracts the domain column from `wp site list --fields=domain +// --format=csv` output (a header line "domain" followed by one domain per line). +func parseSiteListCSV(out []byte) []string { + var domains []string + for _, ln := range strings.Split(string(out), "\n") { + ln = strings.TrimSpace(strings.Trim(ln, "\r")) + if ln == "" || ln == "domain" { + continue + } + domains = append(domains, ln) + } + return domains +} + +// realDeps assembles the production lifecycle.Deps around a runner. +func realDeps(r *dockercli.Runner) lifecycle.Deps { + return lifecycle.Deps{ + Docker: dockerAdapter{r: r}, + Proxy: proxyAdapter{r: r}, + Elevator: elevatorAdapter{}, + Prober: httpProber{}, + Subsites: subsiteAdapter{r: r}, + } +} diff --git a/internal/devenv/adapters_test.go b/internal/devenv/adapters_test.go new file mode 100644 index 000000000..4b8da11b5 --- /dev/null +++ b/internal/devenv/adapters_test.go @@ -0,0 +1,44 @@ +package devenv + +import "testing" + +func TestParseVolumeLines(t *testing.T) { + got := parseVolumeLines([]byte("alpha\nbeta\n\ngamma\n")) + want := []string{"alpha", "beta", "gamma"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } + if len(parseVolumeLines([]byte(""))) != 0 { + t.Fatal("empty input should yield no names") + } +} + +func TestParseSiteListCSV(t *testing.T) { + csv := "domain\nnet.vipdev.site\nsub1.net.vipdev.site\nsub2.net.vipdev.site\n" + got := parseSiteListCSV([]byte(csv)) + want := []string{"net.vipdev.site", "sub1.net.vipdev.site", "sub2.net.vipdev.site"} + if len(got) != len(want) { + t.Fatalf("parseSiteListCSV = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("row %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestParseContainerLines(t *testing.T) { + raw := []byte("abc123\texample_php_1\ndef456\texample_database_1\n\n") + got := parseContainerLines(raw) + if len(got) != 2 { + t.Fatalf("want 2 containers, got %+v", got) + } + if got[0].ID != "abc123" || got[0].Name != "example_php_1" { + t.Fatalf("bad parse: %+v", got[0]) + } +} diff --git a/internal/devenv/adopt.go b/internal/devenv/adopt.go new file mode 100644 index 000000000..8d541f060 --- /dev/null +++ b/internal/devenv/adopt.go @@ -0,0 +1,20 @@ +package devenv + +import "github.com/Automattic/vip/internal/devenv/compose" + +// adoptSetupSteps prepends a one-time recursive chown when a start is adopting a +// pre-existing Lando environment. The reused NoCopy volumes (mu-plugins, and the +// image-mode client-code content) keep Lando's file ownership, so setup.sh — run +// as www-data — cannot overwrite pre-existing root-owned files like +// dev-env-plugin.php in the mu-plugins volume (the base chown is non-recursive: +// it fixes the mount-point dirs, not their contents). Normalizing /wp/wp-content +// to www-data once, as root, before setup.sh runs, fixes it. Greenfield starts +// pass adopting=false, so this never adds per-start cost. +func adoptSetupSteps(base []compose.SetupStep, adopting bool) []compose.SetupStep { + if !adopting { + return base + } + return append([]compose.SetupStep{ + {AsRoot: true, Command: "chown -R www-data:www-data /wp/wp-content"}, + }, base...) +} diff --git a/internal/devenv/adopt_setup_test.go b/internal/devenv/adopt_setup_test.go new file mode 100644 index 000000000..2ad6e263a --- /dev/null +++ b/internal/devenv/adopt_setup_test.go @@ -0,0 +1,30 @@ +package devenv + +import ( + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +func TestAdoptSetupStepsPrependsRecursiveChownWhenAdopting(t *testing.T) { + base := []compose.SetupStep{{AsRoot: false, Command: "sh /dev-tools/setup.sh"}} + + if got := adoptSetupSteps(base, false); len(got) != 1 { + t.Fatalf("non-adopting start must be unchanged, got %+v", got) + } + + got := adoptSetupSteps(base, true) + if len(got) != 2 { + t.Fatalf("adopting start must prepend one step, got %+v", got) + } + if !got[0].AsRoot || !strings.Contains(got[0].Command, "chown -R www-data:www-data /wp/wp-content") { + t.Fatalf("want a recursive root chown first, got %+v", got[0]) + } + if got[1].Command != base[0].Command { + t.Fatalf("original setup steps must follow, got %+v", got[1]) + } + if len(base) != 1 { + t.Fatal("base slice must not be mutated") + } +} diff --git a/internal/devenv/compose/labels.go b/internal/devenv/compose/labels.go new file mode 100644 index 000000000..998b2a225 --- /dev/null +++ b/internal/devenv/compose/labels.go @@ -0,0 +1,90 @@ +package compose + +import ( + "fmt" + "strings" +) + +// hostRule builds a Traefik HostRegexp rule for a hostname, converting a "*" +// wildcard to the [a-z0-9-]+ class (ports lando-proxy/lib/utils.js getRule). +func hostRule(host string) string { + re := strings.ReplaceAll(host, "*", "[a-z0-9-]+") + return fmt.Sprintf("HostRegexp(`%s`)", re) +} + +// routerLabels emits an http router + a tls (secured) router for one routed +// hostname pattern on a given service port, all prefixed by id. +func routerLabels(id, rule string, port int, labels map[string]string) { + labels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", id)] = "http" + labels[fmt.Sprintf("traefik.http.routers.%s.rule", id)] = rule + labels[fmt.Sprintf("traefik.http.routers.%s.service", id)] = id + "-service" + labels[fmt.Sprintf("traefik.http.services.%s-service.loadbalancer.server.port", id)] = fmt.Sprintf("%d", port) + + sec := id + "-secured" + labels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", sec)] = "https" + labels[fmt.Sprintf("traefik.http.routers.%s.rule", sec)] = rule + labels[fmt.Sprintf("traefik.http.routers.%s.tls", sec)] = "true" + labels[fmt.Sprintf("traefik.http.routers.%s.service", sec)] = sec + "-service" + // Build the secured service key from id (not sec) so it resolves to + // "<id>-secured-service" — matching the router .service pointer above and + // Node's ${rule.id}-secured-service (utils.js:205). Using sec here would + // yield "<id>-secured-secured-service" and silently break TLS routing. + labels[fmt.Sprintf("traefik.http.services.%s-secured-service.loadbalancer.server.port", id)] = fmt.Sprintf("%d", port) +} + +// nginxLabels routes the front-end hostname(s) to the nginx service, adding the +// multisite wildcard host when enabled. The nginx image listens on port 80 (its +// listen directive + the EJS proxy entry, which has no explicit port → Lando's +// default 80); routing Traefik to 8080 yields a 502 since nothing listens there. +func nginxLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + base := v.SiteSlug + "." + v.Domain + routerLabels("nginx-"+v.SiteSlug, hostRule(base), 80, labels) + if v.MultisiteEnabled { + routerLabels("nginx-"+v.SiteSlug+"-wild", hostRule("*."+base), 80, labels) + } + return labels +} + +// phpMyAdminLabels routes <slug>-pma.<domain> to phpmyadmin (port 80). +func phpMyAdminLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + routerLabels("pma-"+v.SiteSlug, hostRule(v.SiteSlug+"-pma."+v.Domain), 80, labels) + return labels +} + +// mailpitLabels routes <slug>-mailpit.<domain> to mailpit (port 8025). +func mailpitLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + routerLabels("mailpit-"+v.SiteSlug, hostRule(v.SiteSlug+"-mailpit."+v.Domain), 8025, labels) + return labels +} + +// CertSANs returns the hostnames needing a TLS cert for this environment — the +// set that gets secured (https/tls) Traefik routers above. These SANs are the +// single source of truth for the env's edge certificate: the proxy package +// generates one leaf cert covering them centrally (proxy.EnsureCert), because +// the traefik_openssl image runs no in-service cert machinery (Task 1 findings). +// Consequently the app services carry no cert env or certs volume — TLS +// terminates at the Traefik edge using the file-provider cert built from this list. +func CertSANs(v View) []string { + // Lead with a base-domain wildcard (like Lando's *.lndo.site) so the cert + // covers this env's host AND any one-label subdomain of the base domain — + // every <slug>.<domain>, the -pma/-mailpit hosts, and sibling envs — without + // per-host SANs. The explicit hosts below remain for clarity/exactness, and + // the deeper multisite wildcard (two labels) is still added separately. + sans := []string{ + "*." + v.Domain, + v.SiteSlug + "." + v.Domain, + } + if v.MultisiteEnabled { + sans = append(sans, "*."+v.SiteSlug+"."+v.Domain) + } + if v.PHPMyAdmin { + sans = append(sans, v.SiteSlug+"-pma."+v.Domain) + } + if v.Mailpit { + sans = append(sans, v.SiteSlug+"-mailpit."+v.Domain) + } + return sans +} diff --git a/internal/devenv/compose/labels_test.go b/internal/devenv/compose/labels_test.go new file mode 100644 index 000000000..d6b3128b3 --- /dev/null +++ b/internal/devenv/compose/labels_test.go @@ -0,0 +1,136 @@ +package compose + +import ( + "strings" + "testing" +) + +func TestNginxLabelsSingleSite(t *testing.T) { + v := baseView() // single site + labels := nginxLabels(v) + host := "example.vipdev.lndo.site" + if !labelValueContains(labels, "rule", host) { + t.Fatalf("no router rule for %s in %v", host, labels) + } + if !anyLabelKeyContains(labels, "-secured") || !anyLabelKeyContains(labels, ".tls") { + t.Fatalf("expected an https/tls router: %v", labels) + } + // nginx listens on 80 (not 8080); routing Traefik elsewhere yields a 502. + if !labelValueContains(labels, "loadbalancer.server.port", "80") { + t.Fatalf("expected nginx lb port 80: %v", labels) + } +} + +func TestNginxLabelsMultisiteWildcard(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + labels := nginxLabels(v) + if !anyLabelValueContains(labels, "[a-z0-9-]+") { + t.Fatalf("expected wildcard regex in multisite rule: %v", labels) + } +} + +func TestCertSANsIncludeEnabledServices(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Mailpit = true + sans := CertSANs(v) + joined := strings.Join(sans, ",") + for _, want := range []string{"example.vipdev.lndo.site", "example-pma.vipdev.lndo.site", "example-mailpit.vipdev.lndo.site"} { + if !strings.Contains(joined, want) { + t.Fatalf("SANs missing %q: %v", want, sans) + } + } +} + +func TestCertSANsIncludesBaseDomainWildcard(t *testing.T) { + sans := CertSANs(baseView()) + found := false + for _, s := range sans { + if s == "*.vipdev.lndo.site" { + found = true + } + } + if !found { + t.Fatalf("CertSANs must include the base-domain wildcard *.vipdev.lndo.site (Lando-style subdomain cert): %v", sans) + } +} + +func TestCertSANsCoversMultisiteWildcard(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + sans := CertSANs(v) + var hasBase, hasWild bool + for _, s := range sans { + if s == "example.vipdev.lndo.site" { + hasBase = true + } + if s == "*.example.vipdev.lndo.site" { + hasWild = true + } + } + if !hasBase { + t.Fatalf("CertSANs missing base host: %v", sans) + } + if !hasWild { + t.Fatalf("CertSANs missing multisite wildcard *.example.vipdev.lndo.site (the multisite secured router needs it): %v", sans) + } +} + +// TestEveryRouterServicePointerHasDefinition is a structural invariant: every +// traefik router's .service value must reference a service that actually has a +// loadbalancer.server.port definition. A mismatch (e.g. a double "-secured" +// suffix) means Traefik silently drops that route. Checked across nginx (which +// emits the most routers, incl. the multisite wildcard), pma and mailpit. +func TestEveryRouterServicePointerHasDefinition(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + for name, labels := range map[string]map[string]string{ + "nginx": nginxLabels(v), + "pma": phpMyAdminLabels(v), + "mailpit": mailpitLabels(v), + } { + defined := map[string]bool{} + for k := range labels { + const pre, suf = "traefik.http.services.", ".loadbalancer.server.port" + if strings.HasPrefix(k, pre) && strings.HasSuffix(k, suf) { + defined[strings.TrimSuffix(strings.TrimPrefix(k, pre), suf)] = true + } + } + for k, val := range labels { + if strings.HasPrefix(k, "traefik.http.routers.") && strings.HasSuffix(k, ".service") { + if !defined[val] { + t.Fatalf("[%s] router %s points at undefined service %q; defined=%v", name, k, val, defined) + } + } + if strings.Contains(k, "-secured-secured") { + t.Fatalf("[%s] malformed double-secured key: %s", name, k) + } + } + } +} + +func labelValueContains(labels map[string]string, keySub, valSub string) bool { + for k, val := range labels { + if strings.Contains(k, keySub) && strings.Contains(val, valSub) { + return true + } + } + return false +} +func anyLabelKeyContains(labels map[string]string, sub string) bool { + for k := range labels { + if strings.Contains(k, sub) { + return true + } + } + return false +} +func anyLabelValueContains(labels map[string]string, sub string) bool { + for _, val := range labels { + if strings.Contains(val, sub) { + return true + } + } + return false +} diff --git a/internal/devenv/compose/project.go b/internal/devenv/compose/project.go new file mode 100644 index 000000000..7fc49b5d4 --- /dev/null +++ b/internal/devenv/compose/project.go @@ -0,0 +1,88 @@ +package compose + +// BuildProject assembles the full compose Project for an environment. +func BuildProject(v View) *Project { + p := &Project{ + // Name must equal the slug the runner passes via `-p <slug>` (which + // wins over compose's top-level name: anyway). Aligning them removes + // the Plan-4 ambiguity so exec/logs/ps all key off one project name. + Name: v.SiteSlug, + Services: map[string]*Service{}, + Volumes: map[string]*TopLevelVolume{}, + // ProjectNetwork is per-env (compose names it `<slug>_default`) and carries + // the bare service-name aliases; ProxyNetwork is the shared external proxy + // net. See the ProxyNetwork/ProjectNetwork docs for why backends must stay + // off the shared net (cross-env `database` alias collision). + Networks: map[string]*Network{ + ProjectNetwork: {}, + ProxyNetwork: {External: true, Name: ProxyNetwork}, + }, + } + + // Always-on services. + p.Services["database"] = databaseService(v) + p.Services["memcached"] = memcachedService() + p.Services["php"] = phpService(v) + nginx := nginxService(v) + nginx.Labels = nginxLabels(v) + p.Services["nginx"] = nginx + p.Services["wordpress"] = wordpressService(v) + + // Conditional services. + if v.PHPMyAdmin { + pma := phpMyAdminService() + pma.Labels = phpMyAdminLabels(v) + p.Services["phpmyadmin"] = pma + } + if v.Elasticsearch { + p.Services["elasticsearch"] = elasticsearchService() + } + if v.Mailpit { + mp := mailpitService() + mp.Labels = mailpitLabels(v) + p.Services["mailpit"] = mp + } + if v.Photon { + p.Services["photon"] = photonService() + } + if !v.MuPluginsLocal { + p.Services["vip-mu-plugins"] = vipMuPluginsService(v) + } + if !v.AppCodeLocal { + p.Services["demo-app-code"] = demoAppCodeService() + } + + declareVolumes(p, v) + return p +} + +// declareVolumes declares each named volume referenced by an enabled service, +// marking it external (mapped to a Lando volume name) when migrating. +func declareVolumes(p *Project, v View) { + names := []string{"database_data", "devtools", "scripts"} + if !v.MuPluginsLocal { + names = append(names, "mu-plugins") + } + if !v.AppCodeLocal { + names = append(names, + "clientcode_clientmuPlugins", "clientcode_images", "clientcode_languages", + "clientcode_plugins", "clientcode_private", "clientcode_themes", "clientcode_vipconfig") + } + if v.Elasticsearch { + names = append(names, "search_data") + } + if v.PHPMyAdmin { + names = append(names, "pma_www") + } + + for _, n := range names { + tv := &TopLevelVolume{} + if v.Migrate { + if ext, ok := v.ExternalVolumeNames[n]; ok && ext != "" { + tv.External = true + tv.Name = ext + } + } + p.Volumes[n] = tv + } +} diff --git a/internal/devenv/compose/project_test.go b/internal/devenv/compose/project_test.go new file mode 100644 index 000000000..05d05d45b --- /dev/null +++ b/internal/devenv/compose/project_test.go @@ -0,0 +1,148 @@ +package compose + +import "testing" + +func TestBuildProjectAlwaysOnServices(t *testing.T) { + v := baseView() + p := BuildProject(v) + for _, name := range []string{"database", "memcached", "php", "nginx", "wordpress"} { + if _, ok := p.Services[name]; !ok { + t.Fatalf("missing always-on service %q", name) + } + } + if p.Services["nginx"].Labels["traefik.enable"] != "true" { + t.Fatalf("nginx missing traefik labels") + } + if p.Name != "example" { + t.Fatalf("project name = %q, want example (bare slug, must match -p)", p.Name) + } + if p.Networks[ProxyNetwork] == nil || !p.Networks[ProxyNetwork].External { + t.Fatalf("proxy network not declared external: %+v", p.Networks) + } + if p.Volumes["mu-plugins"] == nil { + t.Fatalf("mu-plugins volume not declared in image mode") + } + if p.Volumes["clientcode_themes"] == nil { + t.Fatalf("clientcode_themes volume not declared in image mode") + } + if p.Volumes["database_data"] == nil { + t.Fatalf("database_data volume missing") + } +} + +func TestBuildProjectConditionalServices(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Elasticsearch = true + v.Mailpit = true + v.Photon = true + p := BuildProject(v) + for _, name := range []string{"phpmyadmin", "elasticsearch", "mailpit", "photon"} { + if _, ok := p.Services[name]; !ok { + t.Fatalf("missing conditional service %q", name) + } + } + if p.Volumes["search_data"] == nil || p.Volumes["pma_www"] == nil { + t.Fatalf("conditional volumes missing: %+v", p.Volumes) + } + if p.Services["phpmyadmin"].Labels["traefik.enable"] != "true" { + t.Fatalf("pma missing labels") + } +} + +func TestBuildProjectLocalModeOmitsInitServicesAndVolumes(t *testing.T) { + v := baseView() + v.MuPluginsLocal = true + v.MuPluginsDir = "/srv/mu" + v.AppCodeLocal = true + v.AppCodeDir = "/srv/app" + p := BuildProject(v) + if _, ok := p.Services["vip-mu-plugins"]; ok { + t.Fatalf("vip-mu-plugins should be absent in local mu-plugins mode") + } + if _, ok := p.Services["demo-app-code"]; ok { + t.Fatalf("demo-app-code should be absent in local appCode mode") + } + if p.Volumes["mu-plugins"] != nil || p.Volumes["clientcode_themes"] != nil { + t.Fatalf("named content volumes should be absent in local mode: %+v", p.Volumes) + } +} + +func TestBuildProjectExternalVolumesWhenMigrating(t *testing.T) { + v := baseView() + v.Migrate = true + v.ExternalVolumeNames = map[string]string{"database_data": "landovipdevexample_database_data"} + p := BuildProject(v) + dv := p.Volumes["database_data"] + if dv == nil || !dv.External || dv.Name != "landovipdevexample_database_data" { + t.Fatalf("database_data not mapped to external Lando name: %+v", dv) + } +} + +// netHas reports whether a service's Networks list contains net. +func netHas(s *Service, net string) bool { + for _, n := range s.Networks { + if n == net { + return true + } + } + return false +} + +// TestBuildProjectNetworkIsolation guards against the cross-environment DB bleed +// bug: backend services must NOT join the shared external proxy network (where a +// bare `database` alias from every env collides under Docker round-robin DNS). +// They live on the per-project network only; the proxy network carries just the +// Traefik-routed edge services. +func TestBuildProjectNetworkIsolation(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Elasticsearch = true + v.Mailpit = true + v.Photon = true + p := BuildProject(v) + + // The per-project network is declared and is NOT external (each env gets its + // own `<slug>_default`, so bare service names resolve within the env only). + if p.Networks[ProjectNetwork] == nil { + t.Fatalf("per-project network %q not declared: %+v", ProjectNetwork, p.Networks) + } + if p.Networks[ProjectNetwork].External { + t.Fatalf("per-project network %q must NOT be external (would re-collide across envs)", ProjectNetwork) + } + + // Backends must be on the per-project network and OFF the shared proxy net. + backends := []string{"database", "memcached", "php", "wordpress", "elasticsearch", "photon", "vip-mu-plugins", "demo-app-code"} + for _, name := range backends { + s, ok := p.Services[name] + if !ok { + t.Fatalf("expected backend service %q", name) + } + if !netHas(s, ProjectNetwork) { + t.Errorf("backend %q not on per-project network %q: %v", name, ProjectNetwork, s.Networks) + } + if netHas(s, ProxyNetwork) { + t.Errorf("backend %q must NOT be on shared proxy network %q (cross-env collision): %v", name, ProxyNetwork, s.Networks) + } + } + + // Edge (Traefik-routed) services must be on BOTH networks: the proxy net so + // the shared Traefik can reach them, and the per-project net to reach backends. + for _, name := range []string{"nginx", "phpmyadmin", "mailpit"} { + s, ok := p.Services[name] + if !ok { + t.Fatalf("expected edge service %q", name) + } + if !netHas(s, ProjectNetwork) || !netHas(s, ProxyNetwork) { + t.Errorf("edge %q must be on both %q and %q: %v", name, ProjectNetwork, ProxyNetwork, s.Networks) + } + } +} + +func TestBuildProjectNameMatchesSlug(t *testing.T) { + v := View{SiteSlug: "example-site"} + p := BuildProject(v) + if p.Name != "example-site" { + t.Fatalf("Project.Name = %q, want the bare slug %q (must match the `-p <slug>` the runner passes)", p.Name, "example-site") + } +} diff --git a/internal/devenv/compose/render.go b/internal/devenv/compose/render.go new file mode 100644 index 000000000..7d0f474a1 --- /dev/null +++ b/internal/devenv/compose/render.go @@ -0,0 +1,54 @@ +package compose + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// RenderCompose marshals the assembled project to docker-compose.yml bytes. +func RenderCompose(v View) ([]byte, error) { + return yaml.Marshal(BuildProject(v)) +} + +// RenderEnvFile renders the .env file consumed by services (host UID/GID). +func RenderEnvFile(v View) string { + return fmt.Sprintf("LANDO_HOST_USER_ID=%s\nLANDO_HOST_GROUP_ID=%s\n", v.HostUID, v.HostGID) +} + +// RenderNginxConf renders nginx/extra.conf. The Node template is currently +// empty boilerplate; emit a minimal valid file. +func RenderNginxConf(_ View) string { + return "# VIP dev-env extra nginx configuration\n" +} + +// SetupStep is a post-start command the lifecycle runs in the php service. +type SetupStep struct { + AsRoot bool + Command string +} + +// SetupSteps ports the EJS php run_as_root + run steps (lines 88-101): chown +// the WordPress content paths to www-data (root), then run setup.sh as the +// service user. The lifecycle (Plan 4) executes these after `up`. +func SetupSteps(v View) []SetupStep { + steps := []SetupStep{ + {AsRoot: true, Command: "chown www-data:www-data /wp/wp-content/mu-plugins /wp/config /wp/log /wp/wp-content/uploads /wp"}, + } + if !v.AppCodeLocal { + steps = append(steps, SetupStep{AsRoot: true, Command: "chown www-data:www-data /wp/wp-content/plugins"}) + } + + var b strings.Builder + fmt.Fprintf(&b, `sh /dev-tools/setup.sh --host database --user root --domain "http://%s.%s/" --title "%s" --wpadmin_password "%s"`, + v.SiteSlug, v.Domain, v.WPTitle, v.AdminPassword) + if v.MultisiteEnabled { + fmt.Fprintf(&b, ` --ms-domain "%s.%s"`, v.SiteSlug, v.Domain) + if v.MultisiteSubdomain { + b.WriteString(" --subdomain") + } + } + steps = append(steps, SetupStep{AsRoot: false, Command: b.String()}) + return steps +} diff --git a/internal/devenv/compose/render_test.go b/internal/devenv/compose/render_test.go new file mode 100644 index 000000000..8dc1aeb79 --- /dev/null +++ b/internal/devenv/compose/render_test.go @@ -0,0 +1,90 @@ +package compose + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestRenderEnvFile(t *testing.T) { + v := baseView() + out := RenderEnvFile(v) + if !strings.Contains(out, "LANDO_HOST_USER_ID=1000") || !strings.Contains(out, "LANDO_HOST_GROUP_ID=1000") { + t.Fatalf(".env missing host ids:\n%s", out) + } +} + +func TestSetupStepsIncludeChownAndSetup(t *testing.T) { + v := baseView() + steps := SetupSteps(v) + var sawChown, sawSetup bool + for _, s := range steps { + if s.AsRoot && strings.Contains(s.Command, "chown www-data:www-data") { + sawChown = true + } + if !s.AsRoot && strings.Contains(s.Command, "/dev-tools/setup.sh") { + sawSetup = true + if !strings.Contains(s.Command, `--domain "http://example.vipdev.lndo.site/"`) { + t.Fatalf("setup.sh domain wrong: %q", s.Command) + } + } + } + if !sawChown || !sawSetup { + t.Fatalf("expected chown + setup steps, got %+v", steps) + } +} + +func TestSetupStepsMultisiteFlags(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + v.MultisiteSubdomain = true + steps := SetupSteps(v) + var setup string + for _, s := range steps { + if strings.Contains(s.Command, "setup.sh") { + setup = s.Command + } + } + if !strings.Contains(setup, "--ms-domain") || !strings.Contains(setup, "--subdomain") { + t.Fatalf("multisite subdomain flags missing: %q", setup) + } +} + +func TestRenderComposeMatchesGolden(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "example", + WPTitle: "Example Dev", + Multisite: json.RawMessage("false"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: instancedata.ComponentConfig{Mode: "image"}, + AppCode: instancedata.ComponentConfig{Mode: "image"}, + PHP: "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + PHPMyAdmin: true, + } + v := NewView(data, Options{}) + out, err := RenderCompose(v) + if err != nil { + t.Fatalf("RenderCompose: %v", err) + } + + goldenPath := filepath.Join("testdata", "full.golden.yml") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenPath, out, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run with UPDATE_GOLDEN=1 once to create): %v", err) + } + if string(out) != string(want) { + t.Fatalf("compose output differs from golden.\n--- got ---\n%s\n--- want ---\n%s", out, want) + } +} diff --git a/internal/devenv/compose/services.go b/internal/devenv/compose/services.go new file mode 100644 index 000000000..c81e7d1ca --- /dev/null +++ b/internal/devenv/compose/services.go @@ -0,0 +1,337 @@ +package compose + +import ( + "fmt" + "strings" +) + +// backendNetworks attaches a service to the per-project network only. Backend +// services must never join ProxyNetwork: their bare service-name alias (e.g. +// `database`) collides across environments on that shared network, so Docker +// round-robin DNS would route one env's traffic to another's. See ProxyNetwork. +func backendNetworks() []string { return []string{ProjectNetwork} } + +// edgeNetworks attaches a Traefik-routed service to both the per-project network +// (so it can reach backends like php/database) and the shared proxy network (so +// the central Traefik proxy can route to it). Only nginx/phpmyadmin/mailpit — +// the services carrying traefik.enable labels — use this. +func edgeNetworks() []string { return []string{ProjectNetwork, ProxyNetwork} } + +// databaseService ports the EJS database service (lines 103-126): a mariadb +// or mysql container with VIP's sql-mode flags and the wordpress DB. +func databaseService(v View) *Service { + isMariaDB := strings.HasPrefix(v.DatabaseImage, "mariadb:") + var command string + if isMariaDB { + command = `docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M` + } else { + command = `docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M --mysql-native-password=ON` + } + return &Service{ + Image: v.DatabaseImage, + Command: command, + Ports: []string{":3306"}, + Environment: map[string]string{ + "MYSQL_ALLOW_EMPTY_PASSWORD": "true", + "MYSQL_USER": "wordpress", + "MYSQL_PASSWORD": "wordpress", + "MYSQL_DATABASE": "wordpress", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "database_data:/var/lib/mysql"}}, + Networks: backendNetworks(), + } +} + +// memcachedService ports the EJS memcached service (lines 128-136). +func memcachedService() *Service { + return &Service{ + Image: "memcached:1.6-alpine", + Command: "memcached -m 64", + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Networks: backendNetworks(), + } +} + +// wpVolumes ports the EJS wpVolumes() function (lines 307-368): the shared +// WordPress content mounts, differing for image vs local muPlugins/appCode. +func wpVolumes(v View) []VolumeMount { + // Order matters: a parent mount must precede its nested mounts. The EJS + // template lists ./wordpress:/wp last, which only works under the docker + // compose plugin (it sorts mounts by destination depth). Standalone + // docker-compose mounts in file order, so ./wordpress:/wp listed after + // ./config:/wp/config would shadow /wp/config (and /wp/log, /wp/.../uploads), + // making them inaccessible -> the run_as_root chown fails. Mount /wp first, + // then its children; /wp/config/integrations-config comes after /wp/config. + vols := []VolumeMount{ + {Short: "./wordpress:/wp"}, + {Short: "./config:/wp/config"}, + {Short: "./log:/wp/log"}, + {Short: "./uploads:/wp/wp-content/uploads"}, + {Short: "./integrations-config:/wp/config/integrations-config"}, + } + + if v.MuPluginsLocal { + vols = append(vols, VolumeMount{Short: v.MuPluginsDir + ":/wp/wp-content/mu-plugins"}) + } else { + vols = append(vols, VolumeMount{Type: "volume", Source: "mu-plugins", Target: "/wp/wp-content/mu-plugins", NoCopy: true}) + } + + if v.AppCodeLocal { + d := v.AppCodeDir + vols = append(vols, + VolumeMount{Short: d + "/client-mu-plugins:/wp/wp-content/client-mu-plugins"}, + VolumeMount{Short: d + "/images:/wp/wp-content/images"}, + VolumeMount{Short: d + "/languages:/wp/wp-content/languages"}, + VolumeMount{Short: d + "/plugins:/wp/wp-content/plugins"}, + VolumeMount{Short: d + "/private:/wp/wp-content/private"}, + VolumeMount{Short: d + "/themes:/wp/wp-content/themes"}, + VolumeMount{Short: d + "/vip-config:/wp/vip-config"}, + ) + } else { + for _, m := range []struct{ src, tgt string }{ + {"clientcode_clientmuPlugins", "/wp/wp-content/client-mu-plugins"}, + {"clientcode_images", "/wp/wp-content/images"}, + {"clientcode_languages", "/wp/wp-content/languages"}, + {"clientcode_plugins", "/wp/wp-content/plugins"}, + {"clientcode_private", "/wp/wp-content/private"}, + {"clientcode_themes", "/wp/wp-content/themes"}, + {"clientcode_vipconfig", "/wp/vip-config"}, + } { + vols = append(vols, VolumeMount{Type: "volume", Source: m.src, Target: m.tgt, NoCopy: true}) + } + } + return vols +} + +// nginxService ports the EJS nginx service (lines 22-34). +func nginxService(v View) *Service { + vols := append([]VolumeMount{{Short: "./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf"}}, wpVolumes(v)...) + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/nginx:latest", + Entrypoint: `/usr/sbin/nginx -g "daemon off;"`, + Volumes: vols, + DependsOn: map[string]DependsOn{"php": {Condition: "service_started"}}, + Networks: edgeNetworks(), + } +} + +// phpService ports the EJS php service (lines 36-101) WITHOUT the run / +// run_as_root steps (those become SetupSteps in Task 9). +func phpService(v View) *Service { + env := map[string]string{ + "LANDO_NO_USER_PERMS": "enable", + "LANDO_NEEDS_EXEC": "1", + // LANDO_APP_NAME is the env slug. Lando set this automatically; the Go + // port must, so the shared php-fpm image's bash.bashrc banner ("shell: + // <name>") and other LANDO_APP_NAME-dependent tooling resolve it. Set + // before the user-env loop below so it stays reserved. + "LANDO_APP_NAME": v.SiteSlug, + } + if v.Xdebug { + env["XDEBUG"] = "enable" + } else { + env["XDEBUG"] = "disable" + } + if v.XdebugConfig != "" { + env["XDEBUG_CONFIG"] = v.XdebugConfig + } + if v.AutologinKey != "" { + env["VIP_DEV_AUTOLOGIN_KEY"] = v.AutologinKey + } + if v.Cron { + env["ENABLE_CRON"] = "1" + } + + dep := map[string]DependsOn{ + "database": {Condition: "service_started"}, + "memcached": {Condition: "service_started"}, + "wordpress": {Condition: "service_completed_successfully"}, + } + if v.Elasticsearch { + dep["elasticsearch"] = DependsOn{Condition: "service_started"} + } + if !v.MuPluginsLocal { + dep["vip-mu-plugins"] = DependsOn{Condition: "service_started"} + } + if !v.AppCodeLocal { + dep["demo-app-code"] = DependsOn{Condition: "service_completed_successfully"} + } + + vols := append([]VolumeMount{ + {Type: "volume", Source: "devtools", Target: "/dev-tools", NoCopy: true}, + {Type: "volume", Source: "scripts", Target: "/scripts", NoCopy: true}, + }, wpVolumes(v)...) + + // User env vars (Plan 5 envvar) are injected last but never override a + // reserved LANDO_*/XDEBUG/etc. key already set above. + for k, val := range v.EnvVars { + if _, reserved := env[k]; !reserved { + env[k] = val + } + } + + return &Service{ + Image: v.PHPImage, + Command: "run.sh", + WorkingDir: "/wp", + EnvFile: []string{".env"}, + Environment: env, + DependsOn: dep, + Volumes: vols, + Networks: backendNetworks(), + } +} + +// wordpressService ports the EJS wordpress init service (lines 191-203). It is +// a run-once (initOnly) container that rsyncs the WP core + dev-tools into +// shared volumes; the initOnly semantics are lifecycle metadata (Task 9). +func wordpressService(v View) *Service { + entry := fmt.Sprintf(`/bin/sh -c '/usr/bin/rsync -ac --delete --chown=%s:%s /wp/ /shared/; /usr/bin/rsync -ac --chown=%s:%s --delete /dev-tools-orig/ /dev-tools/'`, + "${LANDO_HOST_USER_ID}", "${LANDO_HOST_GROUP_ID}", "${LANDO_HOST_USER_ID}", "${LANDO_HOST_GROUP_ID}") + return &Service{ + Image: v.WordPressImage, + Entrypoint: entry, + Volumes: []VolumeMount{ + {Short: "./wordpress:/shared"}, + {Short: "devtools:/dev-tools"}, + {Short: "scripts:/scripts"}, + }, + Networks: backendNetworks(), + } +} + +// phpMyAdminService ports the EJS phpmyadmin service (lines 138-161). +func phpMyAdminService() *Service { + return &Service{ + Image: "phpmyadmin:5", + Command: "/docker-entrypoint.sh apache2-foreground", + Ports: []string{"127.0.0.1::80"}, + Environment: map[string]string{ + "MYSQL_ROOT_PASSWORD": "", + "PMA_HOSTS": "database", + "PMA_PORT": "3306", + "PMA_USER": "root", + "PMA_PASSWORD": "", + "UPLOAD_LIMIT": "4G", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "pma_www:/var/www/html"}}, + Networks: edgeNetworks(), + } +} + +// elasticsearchService ports the EJS elasticsearch service (lines 163-189). +func elasticsearchService() *Service { + return &Service{ + Image: "elasticsearch:8.18.2", + Command: "/usr/local/bin/docker-entrypoint.sh", + Ports: []string{":9200"}, + Deploy: &Deploy{Resources: Resources{Limits: ResourceLimits{Memory: "1GB"}}}, + Environment: map[string]string{ + "ELASTICSEARCH_IS_DEDICATED_NODE": "no", + "ELASTICSEARCH_CLUSTER_NAME": "bespin", + "ELASTICSEARCH_NODE_NAME": "lando", + "ELASTICSEARCH_PORT_NUMBER": "9200", + "discovery.type": "single-node", + "xpack.security.enabled": "false", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "search_data:/usr/share/elasticsearch/data"}}, + Networks: backendNetworks(), + } +} + +// mailpitService ports the EJS mailpit service (lines 256-270). The EJS sets +// `command: /mailpit`, but that only worked under Lando (which strips the image +// entrypoint). The axllent/mailpit image ENTRYPOINT is already ["/mailpit"], so +// in raw docker compose a `command: /mailpit` is appended → `/mailpit /mailpit` +// → "unknown command /mailpit" and the container exits 1. We set NO command and +// let the entrypoint run (same Lando-vs-raw-compose trap as demo-app-code's +// `exit 0`). +func mailpitService() *Service { + return &Service{ + Image: "axllent/mailpit:latest", + Ports: []string{":1025", ":8025"}, + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Networks: edgeNetworks(), + } +} + +// photonService ports the EJS photon service (lines 272-284). +func photonService() *Service { + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/photon:latest", + Command: "/usr/sbin/php-fpm", + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "./uploads:/usr/share/webapps/photon/uploads:ro"}}, + Networks: backendNetworks(), + } +} + +// vipMuPluginsService ports the EJS vip-mu-plugins init service (205-226). +// The View parameter is unused today but kept for builder-call uniformity. +func vipMuPluginsService(_ View) *Service { + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/mu-plugins:0.1", + Command: "/bin/sh /run.sh", + Environment: map[string]string{ + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + "LANDO_HOST_UID": "${LANDO_HOST_USER_ID}", + "LANDO_HOST_GID": "${LANDO_HOST_GROUP_ID}", + }, + Volumes: []VolumeMount{ + {Short: "mu-plugins:/shared"}, + {Type: "volume", Source: "scripts", Target: "/scripts", NoCopy: true}, + }, + Networks: backendNetworks(), + } +} + +// demoAppCodeService ports the EJS demo-app-code init service (228-254). +func demoAppCodeService() *Service { + vols := []VolumeMount{} + for _, m := range []struct{ src, tgt string }{ + {"clientcode_clientmuPlugins", "/clientcode/client-mu-plugins"}, + {"clientcode_images", "/clientcode/images"}, + {"clientcode_languages", "/clientcode/languages"}, + {"clientcode_plugins", "/clientcode/plugins"}, + {"clientcode_private", "/clientcode/private"}, + {"clientcode_themes", "/clientcode/themes"}, + {"clientcode_vipconfig", "/clientcode/vip-config"}, + } { + vols = append(vols, VolumeMount{Short: m.src + ":" + m.tgt}) + } + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/skeleton:latest", + // EJS uses `command: exit 0`, which worked only because Lando wrapped + // service commands in a shell. Raw docker compose exec's the command + // directly and `exit` is a shell builtin (not a binary), so wrap it in + // `sh -c`. compose shlex-parses the quoted string, keeping "exit 0" as + // one arg → /bin/sh -c "exit 0" → a clean no-op exit. + Command: `/bin/sh -c "exit 0"`, + Environment: map[string]string{ + "LANDO_HOST_UID": "${LANDO_HOST_USER_ID}", + "LANDO_HOST_GID": "${LANDO_HOST_GROUP_ID}", + }, + Volumes: vols, + Networks: backendNetworks(), + } +} diff --git a/internal/devenv/compose/services_test.go b/internal/devenv/compose/services_test.go new file mode 100644 index 000000000..73516b3d5 --- /dev/null +++ b/internal/devenv/compose/services_test.go @@ -0,0 +1,258 @@ +package compose + +import ( + "strings" + "testing" +) + +// baseView is the shared test fixture for service builders (image mode for +// muPlugins/appCode unless a test flips the *Local flags). +func baseView() View { + return View{ + SiteSlug: "example", Domain: "vipdev.lndo.site", DatabaseImage: "mysql:8.4", + WordPressImage: "ghcr.io/automattic/vip-container-images/wordpress:trunk", + PHPImage: "php:8.2", AdminPassword: "password", HostUID: "1000", HostGID: "1000", + } +} + +func TestDatabaseServiceMySQL(t *testing.T) { + svc := databaseService(baseView()) + if svc.Image != "mysql:8.4" { + t.Fatalf("image = %q", svc.Image) + } + if !strings.Contains(svc.Command, "--mysql-native-password=ON") { + t.Fatalf("mysql command missing native-password flag: %q", svc.Command) + } + if svc.Environment["MYSQL_DATABASE"] != "wordpress" { + t.Fatalf("MYSQL_DATABASE = %q", svc.Environment["MYSQL_DATABASE"]) + } + if len(svc.Volumes) != 1 || svc.Volumes[0].Short != "database_data:/var/lib/mysql" { + t.Fatalf("db volume wrong: %+v", svc.Volumes) + } +} + +func TestDatabaseServiceMariaDB(t *testing.T) { + v := baseView() + v.DatabaseImage = "mariadb:10.11" + svc := databaseService(v) + if svc.Image != "mariadb:10.11" { + t.Fatalf("image = %q", svc.Image) + } + if !strings.Contains(svc.Command, "NO_AUTO_CREATE_USER") { + t.Fatalf("mariadb command wrong: %q", svc.Command) + } +} + +func TestMemcachedService(t *testing.T) { + svc := memcachedService() + if svc.Image != "memcached:1.6-alpine" || svc.Command != "memcached -m 64" { + t.Fatalf("memcached wrong: %+v", svc) + } +} + +func TestWPVolumesImageMode(t *testing.T) { + v := baseView() // appCode/muPlugins image mode (locals false) + vols := wpVolumes(v) + must := []string{ + "./config:/wp/config", + "./log:/wp/log", + "./uploads:/wp/wp-content/uploads", + "./wordpress:/wp", + "./integrations-config:/wp/config/integrations-config", + } + for _, m := range must { + if !hasShort(vols, m) { + t.Fatalf("wpVolumes missing %q: %+v", m, vols) + } + } + if !hasNamed(vols, "mu-plugins", "/wp/wp-content/mu-plugins") { + t.Fatalf("expected mu-plugins named volume in image mode") + } +} + +func TestWPVolumesLocalMode(t *testing.T) { + v := baseView() + v.MuPluginsLocal = true + v.MuPluginsDir = "/srv/mu" + v.AppCodeLocal = true + v.AppCodeDir = "/srv/app" + vols := wpVolumes(v) + if !hasShort(vols, "/srv/mu:/wp/wp-content/mu-plugins") { + t.Fatalf("local mu-plugins bind missing: %+v", vols) + } + if !hasShort(vols, "/srv/app/plugins:/wp/wp-content/plugins") { + t.Fatalf("local appCode plugins bind missing: %+v", vols) + } +} + +func TestNginxServiceDependsOnPHP(t *testing.T) { + svc := nginxService(baseView()) + if svc.Image != "ghcr.io/automattic/vip-container-images/nginx:latest" { + t.Fatalf("nginx image = %q", svc.Image) + } + if svc.DependsOn["php"].Condition != "service_started" { + t.Fatalf("nginx should depend_on php service_started: %+v", svc.DependsOn) + } + if !hasShort(svc.Volumes, "./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf") { + t.Fatalf("nginx extra.conf mount missing: %+v", svc.Volumes) + } +} + +func TestPHPServiceDependsAndEnv(t *testing.T) { + v := baseView() + v.Xdebug = true + svc := phpService(v) + if svc.WorkingDir != "/wp" || svc.Command != "run.sh" { + t.Fatalf("php working_dir/command wrong: %+v", svc) + } + if svc.Environment["XDEBUG"] != "enable" { + t.Fatalf("xdebug env = %q, want enable", svc.Environment["XDEBUG"]) + } + if svc.DependsOn["database"].Condition != "service_started" { + t.Fatalf("php depends_on database missing") + } + if svc.DependsOn["wordpress"].Condition != "service_completed_successfully" { + t.Fatalf("php depends_on wordpress completed missing: %+v", svc.DependsOn) + } +} + +// TestPHPServiceSetsAppName guards that the php container exports LANDO_APP_NAME +// (the env slug). Lando injected this automatically; the Go port must set it so +// the shared php-fpm image's /etc/bash.bashrc banner ("shell: <name>") and any +// LANDO_APP_NAME-dependent tooling work. It is reserved (a user env var of the +// same name must not override it). +func TestPHPServiceSetsAppName(t *testing.T) { + v := baseView() + v.SiteSlug = "my-env" + if got := phpService(v).Environment["LANDO_APP_NAME"]; got != "my-env" { + t.Fatalf("LANDO_APP_NAME = %q, want the slug %q", got, "my-env") + } + v2 := baseView() + v2.SiteSlug = "my-env" + v2.EnvVars = map[string]string{"LANDO_APP_NAME": "hijacked"} + if got := phpService(v2).Environment["LANDO_APP_NAME"]; got != "my-env" { + t.Fatalf("LANDO_APP_NAME overridden by user env var: got %q want %q", got, "my-env") + } +} + +func TestWordPressInitService(t *testing.T) { + svc := wordpressService(baseView()) + if svc.Image != "ghcr.io/automattic/vip-container-images/wordpress:trunk" { + t.Fatalf("wordpress image = %q", svc.Image) + } + if !hasShort(svc.Volumes, "./wordpress:/shared") { + t.Fatalf("wordpress /shared mount missing: %+v", svc.Volumes) + } +} + +func TestPhpMyAdminService(t *testing.T) { + svc := phpMyAdminService() + if svc.Image != "phpmyadmin:5" { + t.Fatalf("pma image = %q", svc.Image) + } + if svc.Environment["PMA_HOSTS"] != "database" { + t.Fatalf("PMA_HOSTS = %q", svc.Environment["PMA_HOSTS"]) + } + if !hasShort(svc.Volumes, "pma_www:/var/www/html") { + t.Fatalf("pma volume missing: %+v", svc.Volumes) + } +} + +func TestElasticsearchServiceMemoryLimit(t *testing.T) { + svc := elasticsearchService() + if svc.Image != "elasticsearch:8.18.2" { + t.Fatalf("es image = %q", svc.Image) + } + if svc.Deploy == nil || svc.Deploy.Resources.Limits.Memory != "1GB" { + t.Fatalf("es memory limit missing: %+v", svc.Deploy) + } +} + +func TestMailpitAndPhotonAndInitServices(t *testing.T) { + if mailpitService().Image != "axllent/mailpit:latest" { + t.Fatal("mailpit image wrong") + } + // The axllent/mailpit image ENTRYPOINT is already ["/mailpit"]; the EJS + // `command: /mailpit` only worked under Lando (which strips the image + // entrypoint). In raw compose, command is appended → `/mailpit /mailpit` → + // "unknown command /mailpit" and the container exits 1. So set NO command and + // let the entrypoint run. + if got := mailpitService().Command; got != "" { + t.Fatalf("mailpit Command = %q, want empty (image entrypoint /mailpit runs it; a command duplicates the entrypoint)", got) + } + if photonService().Image != "ghcr.io/automattic/vip-container-images/photon:latest" { + t.Fatal("photon image wrong") + } + if vipMuPluginsService(baseView()).Image != "ghcr.io/automattic/vip-container-images/mu-plugins:0.1" { + t.Fatal("mu-plugins image wrong") + } + if demoAppCodeService().Image != "ghcr.io/automattic/vip-container-images/skeleton:latest" { + t.Fatal("skeleton image wrong") + } + // The EJS `exit 0` is a shell builtin; raw docker compose exec's the command + // directly (no Lando shell wrapper), so it MUST be shell-wrapped or the init + // container dies with `exec: "exit": executable file not found`. + if got := demoAppCodeService().Command; got != `/bin/sh -c "exit 0"` { + t.Fatalf("demo-app-code command = %q, want a shell-wrapped no-op", got) + } +} + +func TestPHPServiceInjectsEnvVars(t *testing.T) { + v := View{PHPImage: "php:img", EnvVars: map[string]string{"MY_VAR": "v1"}} + svc := phpService(v) + if svc.Environment["MY_VAR"] != "v1" { + t.Fatalf("user env var not injected into php service: %+v", svc.Environment) + } + // Reserved keys must not be overridden by a user var of the same name. + v2 := View{PHPImage: "php:img", EnvVars: map[string]string{"LANDO_NEEDS_EXEC": "0"}} + if got := phpService(v2).Environment["LANDO_NEEDS_EXEC"]; got != "1" { + t.Fatalf("reserved env var was overridden by user var: got %q want \"1\"", got) + } +} + +func hasShort(vols []VolumeMount, s string) bool { + for _, v := range vols { + if v.Short == s { + return true + } + } + return false +} +func hasNamed(vols []VolumeMount, source, target string) bool { + for _, v := range vols { + if v.Short == "" && v.Source == source && v.Target == target { + return true + } + } + return false +} + +// TestWPVolumesMountParentFirst guards the mount-ordering fix: the /wp parent +// bind must precede its nested children, else standalone docker-compose mounts +// ./wordpress:/wp over /wp/config (etc.), hiding them and breaking the chown. +func TestWPVolumesMountParentFirst(t *testing.T) { + vols := wpVolumes(View{}) + idxOf := func(target string) int { + for i, v := range vols { + if v.Short == target { + return i + } + } + return -1 + } + wp := idxOf("./wordpress:/wp") + if wp < 0 { + t.Fatal("./wordpress:/wp mount missing") + } + for _, child := range []string{"./config:/wp/config", "./log:/wp/log", "./uploads:/wp/wp-content/uploads"} { + ci := idxOf(child) + if ci < 0 || ci < wp { + t.Fatalf("%s (idx %d) must be mounted AFTER ./wordpress:/wp (idx %d)", child, ci, wp) + } + } + cfg := idxOf("./config:/wp/config") + ic := idxOf("./integrations-config:/wp/config/integrations-config") + if ic < cfg { + t.Fatalf("integrations-config (idx %d) must be after /wp/config (idx %d)", ic, cfg) + } +} diff --git a/internal/devenv/compose/testdata/full.golden.yml b/internal/devenv/compose/testdata/full.golden.yml new file mode 100644 index 000000000..a3c9a5918 --- /dev/null +++ b/internal/devenv/compose/testdata/full.golden.yml @@ -0,0 +1,265 @@ +name: example +services: + database: + image: mysql:8.4 + command: docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M --mysql-native-password=ON + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + LANDO_NO_USER_PERMS: "1" + MYSQL_ALLOW_EMPTY_PASSWORD: "true" + MYSQL_DATABASE: wordpress + MYSQL_PASSWORD: wordpress + MYSQL_USER: wordpress + ports: + - :3306 + volumes: + - database_data:/var/lib/mysql + networks: + - default + demo-app-code: + image: ghcr.io/automattic/vip-container-images/skeleton:latest + command: /bin/sh -c "exit 0" + environment: + LANDO_HOST_GID: ${LANDO_HOST_GROUP_ID} + LANDO_HOST_UID: ${LANDO_HOST_USER_ID} + volumes: + - clientcode_clientmuPlugins:/clientcode/client-mu-plugins + - clientcode_images:/clientcode/images + - clientcode_languages:/clientcode/languages + - clientcode_plugins:/clientcode/plugins + - clientcode_private:/clientcode/private + - clientcode_themes:/clientcode/themes + - clientcode_vipconfig:/clientcode/vip-config + networks: + - default + memcached: + image: memcached:1.6-alpine + command: memcached -m 64 + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + LANDO_NO_USER_PERMS: "1" + networks: + - default + nginx: + image: ghcr.io/automattic/vip-container-images/nginx:latest + entrypoint: /usr/sbin/nginx -g "daemon off;" + depends_on: + php: + condition: service_started + volumes: + - ./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf + - ./wordpress:/wp + - ./config:/wp/config + - ./log:/wp/log + - ./uploads:/wp/wp-content/uploads + - ./integrations-config:/wp/config/integrations-config + - type: volume + source: mu-plugins + target: /wp/wp-content/mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_clientmuPlugins + target: /wp/wp-content/client-mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_images + target: /wp/wp-content/images + volume: + nocopy: true + - type: volume + source: clientcode_languages + target: /wp/wp-content/languages + volume: + nocopy: true + - type: volume + source: clientcode_plugins + target: /wp/wp-content/plugins + volume: + nocopy: true + - type: volume + source: clientcode_private + target: /wp/wp-content/private + volume: + nocopy: true + - type: volume + source: clientcode_themes + target: /wp/wp-content/themes + volume: + nocopy: true + - type: volume + source: clientcode_vipconfig + target: /wp/vip-config + volume: + nocopy: true + labels: + traefik.enable: "true" + traefik.http.routers.nginx-example-secured.entrypoints: https + traefik.http.routers.nginx-example-secured.rule: HostRegexp(`example.vipdev.site`) + traefik.http.routers.nginx-example-secured.service: nginx-example-secured-service + traefik.http.routers.nginx-example-secured.tls: "true" + traefik.http.routers.nginx-example.entrypoints: http + traefik.http.routers.nginx-example.rule: HostRegexp(`example.vipdev.site`) + traefik.http.routers.nginx-example.service: nginx-example-service + traefik.http.services.nginx-example-secured-service.loadbalancer.server.port: "80" + traefik.http.services.nginx-example-service.loadbalancer.server.port: "80" + networks: + - default + - vip-dev-env + php: + image: ghcr.io/automattic/vip-container-images/php-fpm:8.2 + command: run.sh + working_dir: /wp + env_file: + - .env + environment: + LANDO_APP_NAME: example + LANDO_NEEDS_EXEC: "1" + LANDO_NO_USER_PERMS: enable + XDEBUG: disable + depends_on: + database: + condition: service_started + demo-app-code: + condition: service_completed_successfully + memcached: + condition: service_started + vip-mu-plugins: + condition: service_started + wordpress: + condition: service_completed_successfully + volumes: + - type: volume + source: devtools + target: /dev-tools + volume: + nocopy: true + - type: volume + source: scripts + target: /scripts + volume: + nocopy: true + - ./wordpress:/wp + - ./config:/wp/config + - ./log:/wp/log + - ./uploads:/wp/wp-content/uploads + - ./integrations-config:/wp/config/integrations-config + - type: volume + source: mu-plugins + target: /wp/wp-content/mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_clientmuPlugins + target: /wp/wp-content/client-mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_images + target: /wp/wp-content/images + volume: + nocopy: true + - type: volume + source: clientcode_languages + target: /wp/wp-content/languages + volume: + nocopy: true + - type: volume + source: clientcode_plugins + target: /wp/wp-content/plugins + volume: + nocopy: true + - type: volume + source: clientcode_private + target: /wp/wp-content/private + volume: + nocopy: true + - type: volume + source: clientcode_themes + target: /wp/wp-content/themes + volume: + nocopy: true + - type: volume + source: clientcode_vipconfig + target: /wp/vip-config + volume: + nocopy: true + networks: + - default + phpmyadmin: + image: phpmyadmin:5 + command: /docker-entrypoint.sh apache2-foreground + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_USER_PERMS: "1" + MYSQL_ROOT_PASSWORD: "" + PMA_HOSTS: database + PMA_PASSWORD: "" + PMA_PORT: "3306" + PMA_USER: root + UPLOAD_LIMIT: 4G + ports: + - 127.0.0.1::80 + volumes: + - pma_www:/var/www/html + labels: + traefik.enable: "true" + traefik.http.routers.pma-example-secured.entrypoints: https + traefik.http.routers.pma-example-secured.rule: HostRegexp(`example-pma.vipdev.site`) + traefik.http.routers.pma-example-secured.service: pma-example-secured-service + traefik.http.routers.pma-example-secured.tls: "true" + traefik.http.routers.pma-example.entrypoints: http + traefik.http.routers.pma-example.rule: HostRegexp(`example-pma.vipdev.site`) + traefik.http.routers.pma-example.service: pma-example-service + traefik.http.services.pma-example-secured-service.loadbalancer.server.port: "80" + traefik.http.services.pma-example-service.loadbalancer.server.port: "80" + networks: + - default + - vip-dev-env + vip-mu-plugins: + image: ghcr.io/automattic/vip-container-images/mu-plugins:0.1 + command: /bin/sh /run.sh + environment: + LANDO_HOST_GID: ${LANDO_HOST_GROUP_ID} + LANDO_HOST_UID: ${LANDO_HOST_USER_ID} + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + volumes: + - mu-plugins:/shared + - type: volume + source: scripts + target: /scripts + volume: + nocopy: true + networks: + - default + wordpress: + image: ghcr.io/automattic/vip-container-images/wordpress:trunk + entrypoint: /bin/sh -c '/usr/bin/rsync -ac --delete --chown=${LANDO_HOST_USER_ID}:${LANDO_HOST_GROUP_ID} /wp/ /shared/; /usr/bin/rsync -ac --chown=${LANDO_HOST_USER_ID}:${LANDO_HOST_GROUP_ID} --delete /dev-tools-orig/ /dev-tools/' + volumes: + - ./wordpress:/shared + - devtools:/dev-tools + - scripts:/scripts + networks: + - default +volumes: + clientcode_clientmuPlugins: {} + clientcode_images: {} + clientcode_languages: {} + clientcode_plugins: {} + clientcode_private: {} + clientcode_themes: {} + clientcode_vipconfig: {} + database_data: {} + devtools: {} + mu-plugins: {} + pma_www: {} + scripts: {} +networks: + default: {} + vip-dev-env: + external: true + name: vip-dev-env diff --git a/internal/devenv/compose/types.go b/internal/devenv/compose/types.go new file mode 100644 index 000000000..18605db08 --- /dev/null +++ b/internal/devenv/compose/types.go @@ -0,0 +1,97 @@ +// Package compose renders a docker-compose.yml (plus .env and nginx +// extra.conf) for a vip dev environment from an instancedata.InstanceData. +// Ports assets/dev-env.lando.template.yml.ejs to a real compose file: the +// Lando type:compose services map ~1:1 to compose services; the Lando +// proxy:/ssl: keys become Traefik labels; run/run_as_root/initOnly become +// lifecycle metadata (SetupSteps). Output is a typed model marshaled with +// yaml.v3 for guaranteed-valid, deterministic YAML. +package compose + +// Project is the top-level docker-compose document. +type Project struct { + Name string `yaml:"name"` + Services map[string]*Service `yaml:"services"` + Volumes map[string]*TopLevelVolume `yaml:"volumes,omitempty"` + Networks map[string]*Network `yaml:"networks,omitempty"` +} + +// Service is one compose service. Field order here is the YAML emission order. +type Service struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command,omitempty"` + Entrypoint string `yaml:"entrypoint,omitempty"` + WorkingDir string `yaml:"working_dir,omitempty"` + EnvFile []string `yaml:"env_file,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + Ports []string `yaml:"ports,omitempty"` + DependsOn map[string]DependsOn `yaml:"depends_on,omitempty"` + Volumes []VolumeMount `yaml:"volumes,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + Networks []string `yaml:"networks,omitempty"` + Deploy *Deploy `yaml:"deploy,omitempty"` +} + +// DependsOn models the long-form depends_on condition. +type DependsOn struct { + Condition string `yaml:"condition"` +} + +// Deploy models the subset of deploy we emit (elasticsearch memory limit). +type Deploy struct { + Resources Resources `yaml:"resources"` +} + +type Resources struct { + Limits ResourceLimits `yaml:"limits"` +} + +type ResourceLimits struct { + Memory string `yaml:"memory"` +} + +// TopLevelVolume is a named volume. When External is true the volume is +// expected to already exist (used for migrating Lando-created data volumes); +// Name then carries the externally-managed volume name. +type TopLevelVolume struct { + External bool `yaml:"external,omitempty"` + Name string `yaml:"name,omitempty"` +} + +// Network is a top-level network reference (the shared proxy network is +// declared external so all environments share it). +type Network struct { + External bool `yaml:"external,omitempty"` + Name string `yaml:"name,omitempty"` +} + +// VolumeMount is one entry of a service's volumes:. Short, when set, emits the +// compact "src:dst[:opts]" string form. Otherwise the long mapping form is +// emitted (used for named volumes with the nocopy option). +type VolumeMount struct { + Short string // compact form; if set, the long fields are ignored + Type string // long form: "volume" or "bind" + Source string + Target string + NoCopy bool +} + +// MarshalYAML emits either the short string or the long mapping form. +func (v VolumeMount) MarshalYAML() (any, error) { + if v.Short != "" { + return v.Short, nil + } + type vol struct { + Nocopy bool `yaml:"nocopy"` + } + type longForm struct { + Type string `yaml:"type"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Volume *vol `yaml:"volume,omitempty"` + } + lf := longForm{Type: v.Type, Source: v.Source, Target: v.Target} + if v.NoCopy { + lf.Volume = &vol{Nocopy: true} + } + return lf, nil +} diff --git a/internal/devenv/compose/types_test.go b/internal/devenv/compose/types_test.go new file mode 100644 index 000000000..0970e9ed5 --- /dev/null +++ b/internal/devenv/compose/types_test.go @@ -0,0 +1,61 @@ +package compose + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestVolumeMountShortAndLongForm(t *testing.T) { + short := VolumeMount{Short: "./config:/wp/config"} + sb, err := yaml.Marshal([]VolumeMount{short}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sb), "- ./config:/wp/config") { + t.Fatalf("short form wrong:\n%s", sb) + } + + long := VolumeMount{ + Type: "volume", + Source: "devtools", + Target: "/dev-tools", + NoCopy: true, + } + lb, err := yaml.Marshal([]VolumeMount{long}) + if err != nil { + t.Fatal(err) + } + got := string(lb) + for _, want := range []string{"type: volume", "source: devtools", "target: /dev-tools", "nocopy: true"} { + if !strings.Contains(got, want) { + t.Fatalf("long form missing %q:\n%s", want, got) + } + } +} + +func TestProjectMarshalsDeterministically(t *testing.T) { + p := &Project{ + Name: "example", + Services: map[string]*Service{ + "memcached": { + Image: "memcached:1.6-alpine", + Command: "memcached -m 64", + Environment: map[string]string{ + "LANDO_NEEDS_EXEC": "1", + }, + }, + }, + } + out, err := yaml.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(out) + for _, want := range []string{"name: example", "services:", "memcached:", "image: memcached:1.6-alpine", "command: memcached -m 64", "LANDO_NEEDS_EXEC:"} { + if !strings.Contains(got, want) { + t.Fatalf("marshaled compose missing %q:\n%s", want, got) + } + } +} diff --git a/internal/devenv/compose/view.go b/internal/devenv/compose/view.go new file mode 100644 index 000000000..55af59f08 --- /dev/null +++ b/internal/devenv/compose/view.go @@ -0,0 +1,202 @@ +package compose + +import ( + "encoding/json" + "strings" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +const ( + // DefaultDomain is the domain NEW envs pin. Automattic owns vipdev.site and + // *.vipdev.site resolves to 127.0.0.1 publicly; the managed hosts block makes + // it work offline too. Legacy/migrated envs keep instancedata.LegacyDomain. + DefaultDomain = "vipdev.site" + // ProxyNetwork is the shared external network that the central Traefik proxy + // and each env's Traefik-routed edge services (nginx/phpmyadmin/mailpit) join + // so the proxy can reach them. Backend services must NOT join it: every env's + // compose registers the bare service name (e.g. `database`) as an alias on + // each network it joins, and those bare aliases collide across environments on + // this shared network, so Docker round-robin DNS would route one env's `wp` to + // another env's database (cross-env data bleed). Backends stay on ProjectNetwork. + ProxyNetwork = "vip-dev-env" + // ProjectNetwork is the per-environment network. Keyed "default", docker + // compose scopes it to `<project>_default` (project == slug), so the bare + // `database`/`memcached`/etc. aliases resolve only within the env. This is the + // isolation Lando got from its per-app network (`<app>_default`) while scoping + // the shared-bridge alias to `<service>.<app>.internal`; plain compose can't + // suppress the bare alias on a shared network, so we keep backends off it. + ProjectNetwork = "default" +) + +// Options carries render-time inputs not stored in InstanceData. +type Options struct { + // Domain overrides DefaultDomain (per-env custom domain; Plan 3/5). + Domain string + // HostUID/HostGID feed the LANDO_HOST_USER_ID/GID env. Defaults "1000". + HostUID string + HostGID string + // Migrate, when true, declares data volumes external (Plan 4 migration). + Migrate bool + // ExternalVolumeNames maps logical volume name -> existing external name + // (only consulted when Migrate is true). + ExternalVolumeNames map[string]string +} + +// View is the fully-resolved, pure input the service/label builders consume. +type View struct { + SiteSlug string + WPTitle string + Domain string + + MultisiteEnabled bool + MultisiteSubdomain bool + + PHPImage string + DatabaseImage string + WordPressImage string + + Xdebug bool + XdebugConfig string + Cron bool + AutologinKey string + AdminPassword string + + PHPMyAdmin bool + Elasticsearch bool + Mailpit bool + Photon bool + + MuPluginsLocal bool + MuPluginsDir string + AppCodeLocal bool + AppCodeDir string + + HostUID string + HostGID string + + Migrate bool + ExternalVolumeNames map[string]string + // EnvVars are per-env user variables injected into the php service + // environment (Plan 5 envvar). Reserved keys win over user keys. + EnvVars map[string]string + // MigratedFromLando carries instancedata's marker into the info table + // (Go-only). Empty for envs never adopted from Lando. + MigratedFromLando string +} + +// NewView derives a View from instance data + options, applying the same +// defaults as preProcessInstanceData (dev-environment-core.ts:317-347). +func NewView(d *instancedata.InstanceData, opts Options) View { + v := View{ + SiteSlug: d.SiteSlug, + WPTitle: d.WPTitle, + Domain: firstNonEmpty(opts.Domain, DefaultDomain), + PHPImage: phpImage(d.PHP), + WordPressImage: "ghcr.io/automattic/vip-container-images/wordpress:" + wordpressTag(d), + Xdebug: d.Xdebug, + XdebugConfig: d.XdebugConfig, + Cron: d.Cron, + AutologinKey: d.AutologinKey, + AdminPassword: firstNonEmpty(d.AdminPassword, "password"), + PHPMyAdmin: d.PHPMyAdmin, + Elasticsearch: truthyRaw(d.Elasticsearch), + Mailpit: d.Mailpit, + Photon: d.Photon, + MuPluginsLocal: d.MuPlugins.Mode == "local", + MuPluginsDir: d.MuPlugins.Dir, + AppCodeLocal: d.AppCode.Mode == "local", + AppCodeDir: d.AppCode.Dir, + HostUID: firstNonEmpty(opts.HostUID, "1000"), + HostGID: firstNonEmpty(opts.HostGID, "1000"), + Migrate: opts.Migrate, + ExternalVolumeNames: opts.ExternalVolumeNames, + EnvVars: d.EnvVars, + MigratedFromLando: d.MigratedFromLando, + } + + v.MultisiteEnabled, v.MultisiteSubdomain = multisite(d.Multisite) + + if d.MariaDB != "" { + v.DatabaseImage = "mariadb:" + d.MariaDB + } else { + v.DatabaseImage = "mysql:8.4" + } + return v +} + +func firstNonEmpty(vals ...string) string { + for _, s := range vals { + if s != "" { + return s + } + } + return "" +} + +func wordpressTag(d *instancedata.InstanceData) string { + if d.WordPress.Tag != "" { + return d.WordPress.Tag + } + return "trunk" +} + +// phpFPMImagePrefix is the VIP php-fpm image repo; a bare version is appended. +const phpFPMImagePrefix = "ghcr.io/automattic/vip-container-images/php-fpm:" + +// defaultPHPImage is the recommended php-fpm image when none is specified — +// the first entry of Node's DEV_ENVIRONMENT_PHP_VERSIONS (8.2, recommended). +const defaultPHPImage = phpFPMImagePrefix + "8.2" + +// phpImage resolves the php-fpm image from instance-data's php field, mirroring +// Node DEV_ENVIRONMENT_PHP_VERSIONS resolution: empty -> recommended default; a +// bare version like "8.3" -> the matching php-fpm image; an explicit image +// reference (already containing "/" or ":") -> used verbatim. Resolving at +// render time matches how wordpressTag/DatabaseImage already default. +func phpImage(php string) string { + if php == "" { + return defaultPHPImage + } + if strings.ContainsAny(php, "/:") { + return php + } + return phpFPMImagePrefix + php +} + +// multisite interprets the bool|string union. bool true => enabled+subdomain +// (per the EJS `multisite === true || === 'subdomain'` subdomain branch); the +// string "subdomain" => enabled+subdomain; any other non-empty string => +// enabled (subdirectory). Mirrors the EJS `if (multisite)` gate. +func multisite(raw json.RawMessage) (enabled, subdomain bool) { + if len(raw) == 0 { + return false, false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b, b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + s = strings.ToLower(s) + if s == "" { + return false, false + } + return true, s == "subdomain" + } + return false, false +} + +func truthyRaw(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s != "" + } + return false +} diff --git a/internal/devenv/compose/view_test.go b/internal/devenv/compose/view_test.go new file mode 100644 index 000000000..2a3e45160 --- /dev/null +++ b/internal/devenv/compose/view_test.go @@ -0,0 +1,120 @@ +package compose + +import ( + "encoding/json" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestNewViewInterpretsMultisiteAndDefaults(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "example", + WPTitle: "Example", + Multisite: json.RawMessage("false"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: instancedata.ComponentConfig{Mode: "image"}, + AppCode: instancedata.ComponentConfig{Mode: "local", Dir: "/srv/example"}, + PHP: "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + } + v := NewView(data, Options{}) + + if v.SiteSlug != "example" { + t.Fatalf("SiteSlug = %q", v.SiteSlug) + } + if v.Domain != DefaultDomain { + t.Fatalf("default Domain = %q, want DefaultDomain %q", v.Domain, DefaultDomain) + } + if v.MultisiteEnabled { + t.Fatalf("multisite should be disabled for false") + } + if v.AdminPassword != "password" { + t.Fatalf("default AdminPassword = %q, want password", v.AdminPassword) + } + if !v.AppCodeLocal || v.AppCodeDir != "/srv/example" { + t.Fatalf("appCode local/dir wrong: %+v", v) + } + if v.MuPluginsLocal { + t.Fatalf("muPlugins should be image mode") + } + if v.DatabaseImage != "mysql:8.4" { + t.Fatalf("default db image = %q, want mysql:8.4", v.DatabaseImage) + } +} + +func TestNewViewSubdomainMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms", + Multisite: json.RawMessage(`"subdomain"`), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MariaDB: "10.11", + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled || !v.MultisiteSubdomain { + t.Fatalf("expected subdomain multisite enabled: %+v", v) + } + if v.DatabaseImage != "mariadb:10.11" { + t.Fatalf("mariadb image = %q", v.DatabaseImage) + } +} + +// TestNewViewBoolTrueMultisite locks the parity-critical branch: a bool `true` +// multisite must enable subdomain routing (EJS `multisite === true` => --subdomain). +func TestNewViewBoolTrueMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms2", + Multisite: json.RawMessage("true"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled || !v.MultisiteSubdomain { + t.Fatalf("bool true multisite should be enabled+subdomain: %+v", v) + } +} + +func TestNewViewCopiesEnvVars(t *testing.T) { + d := &instancedata.InstanceData{SiteSlug: "e", Multisite: json.RawMessage("false"), EnvVars: map[string]string{"A": "1"}} + v := NewView(d, Options{}) + if v.EnvVars["A"] != "1" { + t.Fatalf("NewView did not copy EnvVars: %+v", v.EnvVars) + } +} + +// TestNewViewSubdirectoryMultisite: a non-subdomain string enables multisite +// but NOT subdomain routing. +func TestNewViewSubdirectoryMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms3", + Multisite: json.RawMessage(`"subdirectory"`), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled { + t.Fatalf("subdirectory multisite should be enabled: %+v", v) + } + if v.MultisiteSubdomain { + t.Fatalf("subdirectory multisite must NOT be subdomain: %+v", v) + } +} + +func TestDefaultDomainIsVipdevSite(t *testing.T) { + if DefaultDomain != "vipdev.site" { + t.Fatalf("DefaultDomain = %q, want vipdev.site", DefaultDomain) + } +} + +func TestNewViewResolvesPHPImage(t *testing.T) { + base := func(php string) *instancedata.InstanceData { + return &instancedata.InstanceData{SiteSlug: "e", Multisite: json.RawMessage("false"), PHP: php} + } + cases := []struct{ php, want string }{ + {"", "ghcr.io/automattic/vip-container-images/php-fpm:8.2"}, // empty -> recommended default + {"8.4", "ghcr.io/automattic/vip-container-images/php-fpm:8.4"}, // bare version -> mapped image + {"ghcr.io/automattic/vip-container-images/php-fpm:8.3", "ghcr.io/automattic/vip-container-images/php-fpm:8.3"}, // explicit image -> as-is + } + for _, c := range cases { + if got := NewView(base(c.php), Options{}).PHPImage; got != c.want { + t.Errorf("phpImage(%q) => %q, want %q", c.php, got, c.want) + } + } +} diff --git a/internal/devenv/configfile.go b/internal/devenv/configfile.go new file mode 100644 index 000000000..b8aba45f7 --- /dev/null +++ b/internal/devenv/configfile.go @@ -0,0 +1,300 @@ +package devenv + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// Ports src/lib/dev-environment/dev-environment-configuration-file.ts. +// +// Node walks UP from the working directory looking for a dev-env configuration +// file and, when one is found, uses its `slug` as the environment every +// dev-env command targets (getEnvironmentName, dev-environment-cli.ts:166). +// vip-next ignored the file entirely, so in a configured repo `dev-env destroy` +// tore down whatever environment happened to be the only one on disk instead of +// the configured one (register item 2.21). + +const ( + // configFolder is Node's CONFIGURATION_FOLDER (dev-environment-cli.ts:50). + configFolder = ".wpvip" + // configFileName / configTemplateFileName are CONFIGURATION_FILE_NAME and + // CONFIGURATION_TEMPLATE_FILE_NAME. + configFileName = "vip-dev-env.yml" + configTemplateFileName = "vip-dev-env.yml.ejs" + // configWalkMaxDepth is Node's `maxDepth` sanity bound on the upward walk. + configWalkMaxDepth = 64 +) + +// configFileVersions is Node's CONFIGURATION_FILE_VERSIONS. +var configFileVersions = []string{"1"} + +// ConfigFile is a sanitized dev-env configuration file — the Go equivalent of +// Node's ConfigurationFileOptions plus its `meta['configuration-path']`. +type ConfigFile struct { + // Path is the file the values came from; Node prints it in the + // "Using environment X from Y" line. + Path string + Version string + Slug string + Title string + Multisite string // "", "subdomain", "subdirectory", "true"/"false" + PHP string + WordPress string + MuPlugins string + AppCode string + MediaRedirectDomain string + Overrides string + Elasticsearch *bool + PHPMyAdmin *bool + Xdebug *bool + Mailpit *bool + Photon *bool + Cron *bool +} + +// LoadConfigFile runs the discovery from the process working directory, which +// is what Node uses (findConfigurationFile starts at process.cwd()). +func LoadConfigFile() (*ConfigFile, error) { + wd, err := os.Getwd() + if err != nil { + return nil, nil //nolint:nilerr // no cwd => no configuration, same as Node's read failure + } + return FindConfigFile(wd) +} + +// FindConfigFile walks up from startDir looking for a dev-env configuration +// file, returning nil when there is none. A file that IS found but cannot be +// parsed is a hard error — Node calls exit.withError() there, and silently +// continuing would let a destructive command target the wrong environment. +func FindConfigFile(startDir string) (*ConfigFile, error) { + for _, cand := range configFileCandidates(startDir) { + b, err := os.ReadFile(cand.file) // #nosec G304 -- walked, user-owned repo path + if err != nil { + // Node debug-logs and moves to the next candidate. + continue + } + contents := string(b) + if cand.template { + rendered, rerr := renderConfigTemplate(contents, cand.dir) + if rerr != nil { + return nil, fmt.Errorf("Configuration file %s could not be loaded:\n%s", cand.file, rerr) + } + contents = rendered + } + return sanitizeConfigFile(contents, cand.file) + } + return nil, nil +} + +type configCandidate struct { + dir string + file string + template bool +} + +// configFileCandidates reproduces findConfigurationFile's location list: for +// each directory from startDir upward (stopping AT — not including — the +// filesystem root, and after 64 directories), four candidates in this order: +// +// <dir>/.wpvip/vip-dev-env.yml.ejs (template) +// <dir>/.wpvip/vip-dev-env.yml +// <dir>/.vip-dev-env.yml.ejs (template) +// <dir>/.vip-dev-env.yml +// +// The whole list is built first and then probed in order, so every candidate in +// a nearer directory beats every candidate in an ancestor. +func configFileCandidates(startDir string) []configCandidate { + current := filepath.Clean(startDir) + root := filepath.Dir(current) + for root != filepath.Dir(root) { + root = filepath.Dir(root) + } + + var out []configCandidate + for depth := 0; current != root && depth < configWalkMaxDepth; depth++ { + wpvip := filepath.Join(current, configFolder) + out = append(out, + configCandidate{dir: wpvip, file: filepath.Join(wpvip, configTemplateFileName), template: true}, + configCandidate{dir: wpvip, file: filepath.Join(wpvip, configFileName), template: false}, + configCandidate{dir: current, file: filepath.Join(current, "."+configTemplateFileName), template: true}, + configCandidate{dir: current, file: filepath.Join(current, "."+configFileName), template: false}, + ) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return out +} + +// ejsTagRE matches any remaining EJS tag after configDir substitution. +var ejsTagRE = regexp.MustCompile(`<%[-=_]?[\s\S]*?%>`) + +// configDirTagRE matches the one expression Node's template context provides: +// `configDir` (ejs.render(contents, { configDir: dir })). +var configDirTagRE = regexp.MustCompile(`<%[-=]\s*configDir\s*-?%>`) + +// renderConfigTemplate renders a .ejs configuration template. Node runs a full +// EJS engine with exactly one variable in scope (configDir), which is all the +// documented templates use. Anything richer is refused rather than silently +// mis-rendered: a template we cannot evaluate would otherwise resolve to a +// DIFFERENT environment than the Node CLI targets, on commands that delete data. +func renderConfigTemplate(contents, configDir string) (string, error) { + rendered := configDirTagRE.ReplaceAllLiteralString(contents, configDir) + if tag := ejsTagRE.FindString(rendered); tag != "" { + return "", fmt.Errorf("unsupported EJS expression %q (only <%%= configDir %%> is supported)", tag) + } + return rendered, nil +} + +// sanitizeConfigFile ports sanitizeConfiguration + adjustRelativePaths, +// including the exact error wording Node exits with. +func sanitizeConfigFile(contents, path string) (*ConfigFile, error) { + // yaml.v3 decodes scalars into `any` as typed values; decoding into + // map[string]string-ish accessors below keeps them verbatim, matching + // Node's FAILSAFE_SCHEMA ("Only allow strings, arrays, and objects"). + var node yaml.Node + if err := yaml.Unmarshal([]byte(contents), &node); err != nil { + return nil, fmt.Errorf("Configuration file %s could not be loaded:\n%s", path, err) + } + raw := failsafeMapping(&node) + if raw == nil { + return nil, configGenericError(path) + } + + version, hasVersion := raw["configuration-version"] + slug, hasSlug := raw["slug"] + if !hasVersion || version == "" || !hasSlug { + return nil, configGenericError(path) + } + if !isValidConfigVersion(version) { + return nil, fmt.Errorf( + "Configuration file %s has an invalid configuration-version key. "+ + "Update to a supported version. For example:\n\n%s\nSupported configuration versions: %s.\n", + path, configFileExample(), strings.Join(configFileVersions, ", ")) + } + + cfg := &ConfigFile{ + Path: path, + Version: version, + Slug: slug, + Title: raw["title"], + Multisite: raw["multisite"], + PHP: raw["php"], + WordPress: raw["wordpress"], + MuPlugins: raw["mu-plugins"], + AppCode: raw["app-code"], + MediaRedirectDomain: raw["media-redirect-domain"], + Overrides: raw["overrides"], + Elasticsearch: stringToBoolIfDefined(raw, "elasticsearch"), + PHPMyAdmin: stringToBoolIfDefined(raw, "phpmyadmin"), + Xdebug: stringToBoolIfDefined(raw, "xdebug"), + Mailpit: stringToBoolIfDefined(raw, "mailpit"), + Photon: stringToBoolIfDefined(raw, "photon"), + Cron: stringToBoolIfDefined(raw, "cron"), + } + adjustConfigRelativePaths(cfg) + return cfg, nil +} + +// failsafeMapping returns the document's top-level mapping as string->string, +// or nil when the document is not a mapping (Node: `Array.isArray(configuration) +// || typeof configuration !== 'object'` -> generic error). Nested values are +// not used by any consumer, so only scalars are collected. +func failsafeMapping(doc *yaml.Node) map[string]string { + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil + } + m := doc.Content[0] + if m.Kind != yaml.MappingNode { + return nil + } + out := make(map[string]string, len(m.Content)/2) + for i := 0; i+1 < len(m.Content); i += 2 { + key, val := m.Content[i], m.Content[i+1] + if key.Kind != yaml.ScalarNode { + continue + } + if val.Kind == yaml.ScalarNode { + // Scalar Value is the verbatim source text, which is exactly what + // FAILSAFE_SCHEMA yields (php: 8.10 stays "8.10", not 8.1). + out[key.Value] = val.Value + } + } + return out +} + +// stringToBoolIfDefined ports Node's stringToBooleanIfDefined: only the exact +// strings "true"/"false" produce a value; everything else (including a missing +// key) stays undefined. +func stringToBoolIfDefined(raw map[string]string, key string) *bool { + v, ok := raw[key] + if !ok { + return nil + } + switch v { + case "true": + t := true + return &t + case "false": + f := false + return &f + } + return nil +} + +// adjustConfigRelativePaths ports adjustRelativePaths: app-code and mu-plugins +// are resolved against the configuration file's directory unless they are one +// of the image keywords. +func adjustConfigRelativePaths(cfg *ConfigFile) { + dir := filepath.Dir(cfg.Path) + fix := func(v string) string { + if v == "" || v == "demo" || v == "image" || filepath.IsAbs(v) { + return v + } + return filepath.Join(dir, v) + } + cfg.AppCode = fix(cfg.AppCode) + cfg.MuPlugins = fix(cfg.MuPlugins) +} + +func isValidConfigVersion(v string) bool { + for _, known := range configFileVersions { + if known == v { + return true + } + } + return false +} + +func configGenericError(path string) error { + return fmt.Errorf( + "Configuration file %s is available but couldn't be loaded. "+ + "Ensure there is a configuration-version and slug configured. For example:\n\n%s", + path, configFileExample()) +} + +// configFileExample ports getConfigurationFileExample(). +func configFileExample() string { + return fmt.Sprintf(`configuration-version: %s +slug: dev-site +title: Dev Site +php: 8.2 +wordpress: 6.2 +app-code: ../ +mu-plugins: image +multisite: false +phpmyadmin: false +elasticsearch: false +xdebug: false +mailpit: false +photon: false +cron: false +`, configFileVersions[len(configFileVersions)-1]) +} diff --git a/internal/devenv/configfile_test.go b/internal/devenv/configfile_test.go new file mode 100644 index 000000000..e060bb93b --- /dev/null +++ b/internal/devenv/configfile_test.go @@ -0,0 +1,246 @@ +package devenv + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +const minimalConfig = "configuration-version: 1\nslug: configured-site\n" + +// Register 2.21. Node walks UP from the cwd looking for the dev-env +// configuration file (dev-environment-configuration-file.ts findConfigurationFile). +// vip-next ignored it entirely, so `destroy` in a configured repo targeted +// whatever environment happened to be the only/selected one. +func TestFindConfigFileInCurrentDirectory(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), minimalConfig) + + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if cfg == nil { + t.Fatal("no configuration file found") + } + if cfg.Slug != "configured-site" { + t.Errorf("Slug = %q, want configured-site", cfg.Slug) + } + if want := filepath.Join(dir, ".wpvip", "vip-dev-env.yml"); cfg.Path != want { + t.Errorf("Path = %q, want %q", cfg.Path, want) + } +} + +// Node pushes candidates for the cwd, then its parent, then its parent's +// parent — first readable file wins, so a deep working directory still finds +// the repo-root configuration. +func TestFindConfigFileWalksUp(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".wpvip", "vip-dev-env.yml"), minimalConfig) + deep := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + + cfg, err := FindConfigFile(deep) + if err != nil { + t.Fatal(err) + } + if cfg == nil || cfg.Slug != "configured-site" { + t.Fatalf("walk-up did not find the repo-root configuration: %+v", cfg) + } +} + +// The nearest directory wins over an ancestor. +func TestFindConfigFileNearestWins(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".wpvip", "vip-dev-env.yml"), minimalConfig) + nested := filepath.Join(root, "child") + writeFile(t, filepath.Join(nested, ".wpvip", "vip-dev-env.yml"), + "configuration-version: 1\nslug: nested-site\n") + + cfg, err := FindConfigFile(nested) + if err != nil { + t.Fatal(err) + } + if cfg == nil || cfg.Slug != "nested-site" { + t.Fatalf("nearest configuration must win, got %+v", cfg) + } +} + +// Node also accepts the dotfile form at each level, but `.wpvip/` is checked +// first at the SAME level (locations are pushed in that order). +func TestFindConfigFileDotfileForm(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".vip-dev-env.yml"), "configuration-version: 1\nslug: dotfile-site\n") + + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if cfg == nil || cfg.Slug != "dotfile-site" { + t.Fatalf("dotfile form not found: %+v", cfg) + } +} + +func TestFindConfigFileWpvipBeatsDotfileAtSameLevel(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), minimalConfig) + writeFile(t, filepath.Join(dir, ".vip-dev-env.yml"), "configuration-version: 1\nslug: dotfile-site\n") + + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if cfg == nil || cfg.Slug != "configured-site" { + t.Fatalf(".wpvip/ must win at the same level, got %+v", cfg) + } +} + +// Node's walk is bounded: `depth < maxDepth` with maxDepth = 64, and it stops +// at the filesystem root (the root directory itself is never inspected). +func TestFindConfigFileStopsAfter64Levels(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".wpvip", "vip-dev-env.yml"), minimalConfig) + + // 64 directories below root: the walk visits the start dir plus 63 + // ancestors, so root itself is out of reach. + deep := root + for i := 0; i < 64; i++ { + deep = filepath.Join(deep, "d") + } + if err := os.MkdirAll(deep, 0o755); err != nil { + t.Fatal(err) + } + cfg, err := FindConfigFile(deep) + if err != nil { + t.Fatal(err) + } + if cfg != nil { + t.Errorf("walk must stop after 64 levels, found %q", cfg.Path) + } + + // One level shallower is still in range — pins both sides of the bound so + // the test cannot pass just because the walk is broken. + inRange := filepath.Dir(deep) + cfg, err = FindConfigFile(inRange) + if err != nil { + t.Fatal(err) + } + if cfg == nil { + t.Error("63 levels below the configuration must still find it") + } +} + +func TestFindConfigFileNoneReturnsNil(t *testing.T) { + cfg, err := FindConfigFile(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if cfg != nil { + t.Errorf("expected no configuration file, got %+v", cfg) + } +} + +// Node's sanitizeConfiguration exits with an error when the file lacks +// configuration-version or slug — a broken config must never silently fall +// through to "some other environment" on a destructive command. +func TestFindConfigFileMissingKeysIsFatal(t *testing.T) { + for name, body := range map[string]string{ + "no version": "slug: x\n", + "no slug": "configuration-version: 1\n", + "a list": "- configuration-version: 1\n", + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), body) + _, err := FindConfigFile(dir) + if err == nil { + t.Fatal("want an error, got nil") + } + if !strings.Contains(err.Error(), "couldn't be loaded") { + t.Errorf("error = %q, want Node's \"couldn't be loaded\" wording", err) + } + }) + } +} + +func TestFindConfigFileUnsupportedVersionIsFatal(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), "configuration-version: 2\nslug: x\n") + _, err := FindConfigFile(dir) + if err == nil || !strings.Contains(err.Error(), "invalid configuration-version") { + t.Fatalf("error = %v, want Node's invalid configuration-version message", err) + } +} + +func TestFindConfigFileMalformedYAMLIsFatal(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), "configuration-version: 1\n\tslug: [\n") + _, err := FindConfigFile(dir) + if err == nil || !strings.Contains(err.Error(), "could not be loaded") { + t.Fatalf("error = %v, want Node's could-not-be-loaded message", err) + } +} + +// FAILSAFE_SCHEMA: Node loads the YAML with the failsafe schema so `php: 8.1` +// parses as the STRING "8.1", not the number 8.1 (which would stringify to +// "8.1" here but "8.10" -> "8.1" elsewhere). Values must stay verbatim. +func TestFindConfigFileKeepsNumberLikeValuesAsStrings(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), + "configuration-version: 1\nslug: x\nphp: 8.10\nwordpress: 6.40\n") + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if cfg.PHP != "8.10" { + t.Errorf("PHP = %q, want the verbatim string 8.10", cfg.PHP) + } + if cfg.WordPress != "6.40" { + t.Errorf("WordPress = %q, want the verbatim string 6.40", cfg.WordPress) + } +} + +// adjustRelativePaths resolves app-code / mu-plugins relative to the +// configuration file's directory, leaving the "demo"/"image" keywords alone. +func TestFindConfigFileResolvesRelativeComponentPaths(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), + "configuration-version: 1\nslug: x\napp-code: ../\nmu-plugins: image\n") + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, ".wpvip", ".."); cfg.AppCode != want { + t.Errorf("AppCode = %q, want %q", cfg.AppCode, want) + } + if cfg.MuPlugins != "image" { + t.Errorf("MuPlugins = %q, want the image keyword untouched", cfg.MuPlugins) + } +} + +// Node's slug is used verbatim (configuration.slug.toString()); unlike --slug +// it does NOT go through processSlug, so it is not lowercased. +func TestFindConfigFileSlugIsNotLowercased(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, ".wpvip", "vip-dev-env.yml"), "configuration-version: 1\nslug: MixedCase\n") + cfg, err := FindConfigFile(dir) + if err != nil { + t.Fatal(err) + } + if cfg.Slug != "MixedCase" { + t.Errorf("Slug = %q, want MixedCase (Node does not processSlug the config value)", cfg.Slug) + } +} diff --git a/internal/devenv/create.go b/internal/devenv/create.go new file mode 100644 index 000000000..e26c2dd63 --- /dev/null +++ b/internal/devenv/create.go @@ -0,0 +1,130 @@ +package devenv + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + + "github.com/google/uuid" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +// passwordChars and passwordLength mirror Node's generatePassword +// (dev-environment-database.ts). +const passwordChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" +const passwordLength = 12 + +// generatePassword returns a random 12-character admin password drawn from the +// same charset Node uses, via crypto/rand. +func generatePassword() string { + b := make([]byte, passwordLength) + max := big.NewInt(int64(len(passwordChars))) + for i := range b { + n, err := rand.Int(rand.Reader, max) + if err != nil { + // crypto/rand failure is effectively fatal; fall back to a fixed + // index so we never panic mid-create (vanishingly rare). + b[i] = passwordChars[0] + continue + } + b[i] = passwordChars[n.Int64()] + } + return string(b) +} + +// CreateConfig is the fully-resolved create input (flags + prompt answers). The +// cobra layer (Plan 5) fills this from flags, prompting via internal/appctx for +// anything missing; this package only consumes the resolved struct so it stays +// unit-testable without a TTY. +type CreateConfig struct { + Slug string + Title string + MultisiteMode string // "" (off), "subdomain", or "subdirectory" + PHP string + WordPress string + MuPluginsDir string // local path; "" => image mode + AppCodeDir string // local path; "" => demo/image mode + Elasticsearch bool + PHPMyAdmin bool + Mailpit bool + Xdebug bool + XdebugConfig string + Cron bool + Photon bool + MediaDomain string + Domain string // custom domain; "" => compose.DefaultDomain (vipdev.site) + Start bool // --start: run Start after a successful create +} + +// buildInstanceData converts a resolved CreateConfig into InstanceData, setting +// Multisite explicitly (false or the mode string) — never nil (Node parity). +func buildInstanceData(c CreateConfig) *instancedata.InstanceData { + domain := c.Domain + if domain == "" { + domain = compose.DefaultDomain + } + ms := json.RawMessage("false") + switch c.MultisiteMode { + case "subdomain": + ms = json.RawMessage(`"subdomain"`) + case "subdirectory": + ms = json.RawMessage(`"subdirectory"`) + } + d := &instancedata.InstanceData{ + SiteSlug: c.Slug, + WPTitle: c.Title, + Multisite: ms, + PHP: c.PHP, + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: c.WordPress}, + MuPlugins: componentConfig(c.MuPluginsDir), + AppCode: componentConfig(c.AppCodeDir), + PHPMyAdmin: c.PHPMyAdmin, + Mailpit: c.Mailpit, + Photon: c.Photon, + Xdebug: c.Xdebug, + XdebugConfig: c.XdebugConfig, + Cron: c.Cron, + MediaRedirectDomain: c.MediaDomain, + Domain: domain, + } + if c.Elasticsearch { + d.Elasticsearch = json.RawMessage("true") + } + return d +} + +func componentConfig(dir string) instancedata.ComponentConfig { + if dir != "" { + return instancedata.ComponentConfig{Mode: "local", Dir: dir} + } + return instancedata.ComponentConfig{Mode: "image"} +} + +// writeNewEnv validates the slug is free, then writes instance-data + materializes +// the compose files. It does NOT start (the caller honors CreateConfig.Start). +func writeNewEnv(c CreateConfig) error { + if c.Slug == "" { + return fmt.Errorf("devenv: create requires a slug") + } + if instancedata.Exists(c.Slug) { + return fmt.Errorf("devenv: environment %q already exists", c.Slug) + } + d := buildInstanceData(c) + // Generate and persist credentials at create (Node parity): a random admin + // password (vip-dev-env-create.js) and a UUID autologin key + // (createEnvironment, dev-environment-core.ts). These feed the WordPress + // install and the info table's LOGIN URL / DEFAULT PASSWORD rows. + d.AdminPassword = generatePassword() + d.AutologinKey = uuid.NewString() + if err := instancedata.Write(c.Slug, d); err != nil { + return err + } + view := compose.NewView(d, compose.Options{Domain: d.Domain}) + if _, err := Materialize(c.Slug, view); err != nil { + return err + } + return nil +} diff --git a/internal/devenv/create_test.go b/internal/devenv/create_test.go new file mode 100644 index 000000000..84e25cc93 --- /dev/null +++ b/internal/devenv/create_test.go @@ -0,0 +1,98 @@ +package devenv + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestBuildInstanceDataDefaults(t *testing.T) { + d := buildInstanceData(CreateConfig{Slug: "example", Title: "Example"}) + if d.SiteSlug != "example" || d.WPTitle != "Example" { + t.Fatalf("slug/title not set: %+v", d) + } + // Multisite MUST be explicit false (never nil) — Node parity. + var ms bool + if err := json.Unmarshal(d.Multisite, &ms); err != nil || ms != false { + t.Fatalf("multisite must be explicit false, got %s", string(d.Multisite)) + } +} + +func TestBuildInstanceDataCustomDomainAndMultisite(t *testing.T) { + d := buildInstanceData(CreateConfig{Slug: "ms", Title: "MS", MultisiteMode: "subdomain", Domain: "mysite.test", PHP: "8.3"}) + if d.Domain != "mysite.test" { + t.Fatalf("domain not stored: %q", d.Domain) + } + var s string + if err := json.Unmarshal(d.Multisite, &s); err != nil || s != "subdomain" { + t.Fatalf("multisite subdomain not stored: %s", string(d.Multisite)) + } + if d.PHP != "8.3" { + t.Fatalf("php not stored: %q", d.PHP) + } +} + +func TestWriteNewEnvRejectsExisting(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("dup", &instancedata.InstanceData{SiteSlug: "dup", Multisite: json.RawMessage("false")}); err != nil { + t.Fatal(err) + } + if err := writeNewEnv(CreateConfig{Slug: "dup"}); err == nil { + t.Fatal("expected error creating an env that already exists") + } +} + +func TestWriteNewEnvRequiresSlug(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := writeNewEnv(CreateConfig{Slug: ""}); err == nil { + t.Fatal("expected error when slug empty") + } +} + +func TestGeneratePasswordFormat(t *testing.T) { + const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" + pw := generatePassword() + if len(pw) != 12 { + t.Fatalf("password length = %d, want 12 (%q)", len(pw), pw) + } + for _, c := range pw { + if !strings.ContainsRune(allowed, c) { + t.Fatalf("password %q contains disallowed char %q", pw, c) + } + } +} + +func TestBuildInstanceDataPinsDomain(t *testing.T) { + // No custom domain -> pin the new default explicitly (NOT empty). + d := buildInstanceData(CreateConfig{Slug: "x", Title: "X", PHP: "8.4", WordPress: "trunk"}) + if d.Domain != compose.DefaultDomain { + t.Fatalf("Domain = %q, want pinned default %q", d.Domain, compose.DefaultDomain) + } + // Custom domain wins. + d2 := buildInstanceData(CreateConfig{Slug: "x", Title: "X", PHP: "8.4", WordPress: "trunk", Domain: "my.test"}) + if d2.Domain != "my.test" { + t.Fatalf("Domain = %q, want my.test", d2.Domain) + } +} + +// Node generates a random adminPassword and a UUID autologinKey at create and +// persists both so the info table can show LOGIN URL + DEFAULT PASSWORD. +func TestWriteNewEnvGeneratesCredentials(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := writeNewEnv(CreateConfig{Slug: "creds"}); err != nil { + t.Fatal(err) + } + d, err := instancedata.Read("creds") + if err != nil { + t.Fatal(err) + } + if len(d.AdminPassword) != 12 || d.AdminPassword == "password" { + t.Fatalf("adminPassword not generated: %q", d.AdminPassword) + } + if d.AutologinKey == "" { + t.Fatalf("autologinKey not generated") + } +} diff --git a/internal/devenv/devenv.go b/internal/devenv/devenv.go new file mode 100644 index 000000000..38d9c636b --- /dev/null +++ b/internal/devenv/devenv.go @@ -0,0 +1,416 @@ +package devenv + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/Automattic/vip/internal/version" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/devlog" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/lifecycle" + "github.com/Automattic/vip/internal/devenv/paths" + "github.com/Automattic/vip/internal/devenv/proxy" + "github.com/Automattic/vip/internal/httpproxy" +) + +// newRunner builds the production docker runner. DockerSocket() is called once +// (intentional DOCKER_HOST side effect — Plan 1 parity). When ctx carries a +// session logger (set by the cobra layer for create/start), the runner tees its +// docker output into that per-env log. +func newRunner(ctx context.Context) (*dockercli.Runner, error) { + if _, err := dockercli.DockerSocket(); err != nil { + return nil, err + } + return &dockercli.Runner{Log: devlog.FromContext(ctx)}, nil +} + +func goos() string { return runtime.GOOS } + +// logBanner assembles the diagnostic banner written to the head of a fresh +// per-invocation log (ports writeLogBanner, dev-environment-lando.ts). +func logBanner(ctx context.Context, r *dockercli.Runner) devlog.Banner { + return devlog.Banner{ + Command: strings.Join(os.Args, " "), + OS: fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH), + CLI: version.Version, + Runtime: "go", + Docker: r.Versions(ctx), + RAMGB: "unknown", + CPUs: strconv.Itoa(runtime.NumCPU()), + } +} + +// StartOptions tunes a start. +type StartOptions struct { + // SkipRebuild only starts services that are not already running (omits the + // compose --force-recreate), matching Node `start --skip-rebuild`. + SkipRebuild bool + // Lando, when non-nil and Detected, adopts a pre-existing Lando environment + // for this slug before starting: its old containers/proxy are removed and its + // data volume reused. Set by the cobra layer after PlanLandoMigration + a + // confirmation prompt. + Lando *lifecycle.MigrationPlan +} + +// Create writes a new env and (when c.Start) starts it. +func Create(ctx context.Context, c CreateConfig) error { + if err := writeNewEnv(c); err != nil { + return err + } + if c.Start { + return Start(ctx, c.Slug, StartOptions{}) + } + return nil +} + +// Start materializes + starts an existing env (detecting migration on first run). +func Start(ctx context.Context, slug string, opts StartOptions) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + return startStack(ctx, r, realDeps(r), slug, opts) +} + +// PlanLandoMigration detects a pre-existing Lando footprint for slug so the cobra +// layer can prompt before an irreversible adoption. It builds a runner and +// delegates to lifecycle.DetectLandoMigration. +func PlanLandoMigration(ctx context.Context, slug string) (lifecycle.MigrationPlan, error) { + r, err := newRunner(ctx) + if err != nil { + return lifecycle.MigrationPlan{}, err + } + return lifecycle.DetectLandoMigration(ctx, dockerAdapter{r: r}, slug) +} + +// Rebuild downs (keeping volumes) + orphan-guards, then re-runs the start stack. +func Rebuild(ctx context.Context, slug string) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + deps := realDeps(r) + if err := lifecycle.Rebuild(ctx, deps.Docker, deps.Proxy, slug); err != nil { + return err + } + return startStack(ctx, r, deps, slug, StartOptions{}) +} + +// startStack reads instance-data, runs one-time migration detection, materializes +// the compose files, pull-gates images, and runs lifecycle.Start. Shared by Start +// and Rebuild. +func startStack(ctx context.Context, r *dockercli.Runner, deps lifecycle.Deps, slug string, opts StartOptions) error { + // Write the diagnostic banner to the head of a fresh per-invocation log + // (Node parity: writeLogBanner). No-op if the file already has content. + if r.Log != nil { + _ = r.Log.WriteBanner(logBanner(ctx, r)) + } + d, err := instancedata.Read(slug) + if err != nil { + return err + } + view := compose.NewView(d, compose.Options{ + Domain: d.Domain, + Migrate: len(d.ExternalVolumes) > 0, + ExternalVolumeNames: d.ExternalVolumes, + }) + if _, err := Materialize(slug, view); err != nil { + return err + } + // One-time Lando adoption: after materialize (so `compose down` finds the + // project's compose file) and before Start (so the Go proxy/containers come up + // clean on the reused data volume). + if opts.Lando != nil && opts.Lando.Detected { + if err := lifecycle.AdoptLando(ctx, deps, slug, *opts.Lando); err != nil { + return err + } + d.MigratedFromLando = time.Now().UTC().Format(time.RFC3339) + if err := instancedata.Write(slug, d); err != nil { + return err + } + } + pull := lifecycle.ShouldPull(time.Now(), d.PullAfter, registryReachable()) + if pull { + if err := r.Compose(ctx, slug, "pull"); err != nil { + return err + } + now := time.Now().Unix() + d.PullAfter = &now + _ = instancedata.Write(slug, d) // best-effort: pull succeeded; a stale timestamp only re-pulls next time + } + _, err = lifecycle.Start(ctx, deps, lifecycle.StartParams{ + Project: slug, + View: view, + CertSANs: compose.CertSANs(view), + HostsAdd: envHosts(view, nil), + InitServices: initServices(view), + SetupSteps: adoptSetupSteps(compose.SetupSteps(view), opts.Lando != nil && opts.Lando.Detected), + Pull: pull, + SkipRebuild: opts.SkipRebuild, + GOOS: goos(), + }) + return err +} + +// Stop stops an env's containers. +func Stop(ctx context.Context, slug string) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + return lifecycle.Stop(ctx, dockerAdapter{r: r}, slug) +} + +// StopAll stops every on-disk environment (Node `dev-env stop --all`). +func StopAll(ctx context.Context) error { + return stopEachEnv(instancedata.AllNames(), func(slug string) error { + return Stop(ctx, slug) + }) +} + +// stopEachEnv / purgeEachEnv apply an operation to every environment, CONTINUING +// past a failure and reporting all of them at the end. Node wraps each iteration +// of `stop --all` (vip-dev-env-stop.js:72-100) and `purge` +// (vip-dev-env-purge.js:85-98) in its own try/catch: it prints the error, sets +// process.exitCode = 1 and moves on. Returning on the first error instead left +// the remaining environments running / half-purged. +func stopEachEnv(names []string, stop func(string) error) error { + return eachEnv(names, stop) +} + +func purgeEachEnv(names []string, destroy func(string) error) error { + return eachEnv(names, destroy) +} + +// purgeEnvStep is the per-environment body of Purge, split out from the Docker +// plumbing so its error handling is unit-testable. +// +// The removal error is PROPAGATED. Node does the removal inside +// destroyEnvironment with a bare `fs.promises.rm(instancePath, {recursive: +// true})` — no `force: true` (dev-environment-core.ts:381-382) — so a failure +// rejects and the purge bin sets `process.exitCode = 1` +// (vip-dev-env-purge.js:92-97). Dropping it left an environment on disk (still +// listed by `dev-env list`, still counted by instancedata.AllNames()) while +// purge exited 0. The single-environment Destroy path always propagated it. +func purgeEnvStep(destroy, removeFiles func(string) error, soft bool) func(string) error { + return func(slug string) error { + if err := destroy(slug); err != nil { + return err + } + if soft { + // A soft purge keeps the env's config files so it can be recreated. + return nil + } + return removeFiles(slug) + } +} + +func eachEnv(names []string, fn func(string) error) error { + var errs []error + for _, name := range names { + if err := fn(name); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + } + } + return errors.Join(errs...) +} + +// Destroy tears down an env's containers/volumes. When soft is true the env's +// config files (and its /etc/hosts entry) are KEPT so it can be recreated (Node +// `--soft`); otherwise the env dir is removed and /etc/hosts is recomputed for +// the remaining envs. +func Destroy(ctx context.Context, slug string, soft bool) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + deps := realDeps(r) + + hadCustom := false + if d, err := instancedata.Read(slug); err == nil { + hadCustom = len(envHosts(compose.NewView(d, compose.Options{Domain: d.Domain}), nil)) > 0 + } + + // A soft destroy leaves the env on disk, so it still counts as remaining + // (keeps the shared proxy alive for a later restart). + remaining := len(instancedata.AllNames()) + if !soft { + remaining-- + } + if err := lifecycle.Destroy(ctx, deps.Docker, deps.Proxy, slug, remaining); err != nil { + return err + } + if soft { + return nil + } + if err := removeEnvFiles(slug); err != nil { + return err + } + // Recompute the managed /etc/hosts block for the remaining envs — only when + // the file was or will be affected, to avoid a needless sudo prompt. + remain := remainingHosts() + if hadCustom || len(remain) > 0 { + plan := hostops.PrivilegedPlan{GOOS: goos()} + if len(remain) > 0 { + plan.HostsAdd = remain + } else { + plan.HostsRemove = true + } + return deps.Elevator.Apply(plan) + } + return nil +} + +// Purge destroys every env, cleans up the shared proxy, and clears the managed +// /etc/hosts block (only when some env had custom-domain entries — avoids a +// needless sudo prompt). +func Purge(ctx context.Context, soft bool) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + deps := realDeps(r) + hadCustom := len(remainingHosts()) > 0 + // Pass remaining=1 so lifecycle.Destroy never runs its per-env proxy cleanup + // (which fires only at remaining==0). Purge owns a single Proxy.Cleanup below, + // after every env is torn down. + // + // A failing environment must NOT abort the purge (Node continues and exits + // 1); the shared proxy + hosts cleanup below still has to run, otherwise one + // wedged environment leaves the machine half-purged. + destroyErr := purgeEachEnv(instancedata.AllNames(), purgeEnvStep( + func(slug string) error { return lifecycle.Destroy(ctx, deps.Docker, deps.Proxy, slug, 1) }, + removeEnvFiles, + soft, + )) + if err := deps.Proxy.Cleanup(ctx); err != nil { + return errors.Join(destroyErr, err) + } + // A soft purge keeps every env's config files + /etc/hosts entries. + if !soft && hadCustom { + return errors.Join(destroyErr, deps.Elevator.Apply(hostops.PrivilegedPlan{GOOS: goos(), HostsRemove: true})) + } + return destroyErr +} + +// Info returns a human-readable summary of an env (URL from bound ports + status). +func Info(ctx context.Context, slug string) (string, error) { + r, err := newRunner(ctx) + if err != nil { + return "", err + } + d, err := instancedata.Read(slug) + if err != nil { + return "", err + } + view := compose.NewView(d, compose.Options{Domain: d.Domain}) + ports, _ := proxy.LoadPorts(proxy.PortsStatePath()) + states, _ := dockerAdapter{r: r}.ComposePS(ctx, slug) + return renderEnvInfo(slug, view, ports, states), nil +} + +// InfoAll returns the Info summary for every on-disk environment, separated by +// a blank line (Node `dev-env info --all`). +func InfoAll(ctx context.Context) (string, error) { + names := instancedata.AllNames() + if len(names) == 0 { + return "No local environments found.\n", nil + } + var b strings.Builder + for i, name := range names { + if s, err := Info(ctx, name); err != nil { + fmt.Fprintf(&b, "Environment: %s\n error: %v\n", name, err) + } else { + b.WriteString(s) + } + if i < len(names)-1 { + b.WriteString("\n") + } + } + return b.String(), nil +} + +// registryProbeURL is the registry the reachability check HEADs. A var so the +// proxy-policy test can point it at a local server. +var registryProbeURL = "https://ghcr.io/" + +// registryReachable does a quick HEAD to ghcr.io to gate image pulls. +// +// ghcr.io is off-box, so it follows vip-next's proxy policy rather than +// http.DefaultTransport's: a developer behind the VIP SOCKS proxy has no other +// route out, and one who declined VIP_USE_SYSTEM_PROXY must not have the probe +// silently pushed through an ambient HTTPS_PROXY. See internal/httpproxy. +func registryReachable() bool { + c := httpproxy.ClientWithTimeout(3 * time.Second) + resp, err := c.Head(registryProbeURL) + if err != nil { + return false + } + _ = resp.Body.Close() + return true +} + +// viewForData builds the default render View for an env's instance data. +func viewForData(d *instancedata.InstanceData) compose.View { + return compose.NewView(d, compose.Options{Domain: d.Domain}) +} + +// envHosts returns the hostnames an env needs in the managed hosts block: the +// front-end host plus the pma/mailpit hosts for enabled services, then any +// already-discovered multisite subsite hosts. Applies to ALL envs now (the +// managed block is the offline supplement; online the public wildcard covers it). +func envHosts(v compose.View, subsiteHosts []string) []string { + hosts := []string{v.SiteSlug + "." + v.Domain} + if v.PHPMyAdmin { + hosts = append(hosts, v.SiteSlug+"-pma."+v.Domain) + } + if v.Mailpit { + hosts = append(hosts, v.SiteSlug+"-mailpit."+v.Domain) + } + hosts = append(hosts, subsiteHosts...) + return hosts +} + +// remainingHosts is the union of fixed hosts across every env still on disk +// (used to recompute the hosts block after a destroy). Subsite hosts are not +// re-discovered here; they refresh on each env's next start. +func remainingHosts() []string { + var all []string + for _, name := range instancedata.AllNames() { + d, err := instancedata.Read(name) + if err != nil { + continue + } + view := compose.NewView(d, compose.Options{Domain: d.Domain}) + all = append(all, envHosts(view, nil)...) + } + return all +} + +// initServices lists the one-shot init services to wait for. Names are verified +// against compose/project.go: wordpress init = "wordpress", mu-plugins = "vip-mu-plugins", +// app-code = "demo-app-code". +func initServices(v compose.View) []string { + svcs := []string{"wordpress"} + if !v.MuPluginsLocal { + svcs = append(svcs, "vip-mu-plugins") + } + if !v.AppCodeLocal { + svcs = append(svcs, "demo-app-code") + } + return svcs +} + +func removeEnvFiles(slug string) error { + return os.RemoveAll(paths.EnvironmentPath(slug)) +} diff --git a/internal/devenv/devenv_test.go b/internal/devenv/devenv_test.go new file mode 100644 index 000000000..bc3ccb420 --- /dev/null +++ b/internal/devenv/devenv_test.go @@ -0,0 +1,77 @@ +package devenv + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// TestEnvHostsDefaultDomainIncluded verifies that default-domain envs (vipdev.site) +// DO get a managed hosts block entry now — previously default-domain envs were +// excluded and relied solely on the public *.vipdev.site wildcard (which resolves +// to 127.0.0.1 online). The managed block is the OFFLINE supplement, so every env +// needs it regardless of domain. +func TestEnvHostsDefaultDomainIncluded(t *testing.T) { + got := envHosts(compose.View{SiteSlug: "example", Domain: compose.DefaultDomain}, nil) + if len(got) == 0 { + t.Fatalf("expected at least one hosts entry for default domain %q, got none", compose.DefaultDomain) + } + if got[0] != "example."+compose.DefaultDomain { + t.Fatalf("first entry = %q, want %q", got[0], "example."+compose.DefaultDomain) + } +} + +func TestEnvHostsCustomDomain(t *testing.T) { + v := compose.View{SiteSlug: "example", Domain: "mysite.test", PHPMyAdmin: true, Mailpit: true} + got := envHosts(v, nil) + want := map[string]bool{ + "example.mysite.test": true, + "example-pma.mysite.test": true, + "example-mailpit.mysite.test": true, + } + if len(got) != len(want) { + t.Fatalf("got %v", got) + } + for _, h := range got { + if !want[h] { + t.Fatalf("unexpected host %q in %v", h, got) + } + } +} + +func TestEnvHostsForDefaultDomain(t *testing.T) { + v := compose.View{SiteSlug: "demo", Domain: "vipdev.site", PHPMyAdmin: true, Mailpit: true} + got := envHosts(v, nil) + want := map[string]bool{ + "demo.vipdev.site": true, + "demo-pma.vipdev.site": true, + "demo-mailpit.vipdev.site": true, + } + if len(got) != len(want) { + t.Fatalf("envHosts = %v, want keys %v", got, want) + } + for _, h := range got { + if !want[h] { + t.Fatalf("unexpected host %q in %v", h, got) + } + } +} + +func TestEnvHostsOmitsDisabledServices(t *testing.T) { + v := compose.View{SiteSlug: "demo", Domain: "vipdev.site"} // no pma/mailpit + got := envHosts(v, nil) + if len(got) != 1 || got[0] != "demo.vipdev.site" { + t.Fatalf("envHosts = %v, want only demo.vipdev.site", got) + } +} + +func TestInitServicesImageVsLocal(t *testing.T) { + full := initServices(compose.View{}) // image mode for both + if len(full) < 1 { + t.Fatal("expected at least the wordpress init service") + } + local := initServices(compose.View{MuPluginsLocal: true, AppCodeLocal: true}) + if len(local) != 1 { + t.Fatalf("local mu-plugins+app-code should leave only the wordpress init service, got %v", local) + } +} diff --git a/internal/devenv/devexec.go b/internal/devenv/devexec.go new file mode 100644 index 000000000..f04d29ac4 --- /dev/null +++ b/internal/devenv/devexec.go @@ -0,0 +1,137 @@ +package devenv + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/Automattic/vip/internal/devenv/devlog" + "github.com/Automattic/vip/internal/devenv/devterm" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// phpService is where wp-cli + WordPress live (verified against compose/services.go). +const phpService = "php" + +// shellUserMap mirrors the Node dev-env shell userMap (vip-dev-env-shell.js). +var shellUserMap = map[string]string{ + "nginx": "www-data", "php": "www-data", "database": "mysql", + "memcached": "memcache", "elasticsearch": "elasticsearch", + "phpmyadmin": "www-data", "mailpit": "root", "photon": "root", +} + +// shellUser resolves the container user for a shell: root when forced, else the +// service's mapped user, else www-data. +func shellUser(service string, root bool) string { + if root { + return "root" + } + if u, ok := shellUserMap[service]; ok { + return u + } + return "www-data" +} + +// execOpts returns the leading `exec` token(s). When noTTY is set (stdin is not +// a terminal) it appends -T to disable docker compose's default pseudo-TTY +// allocation, which would otherwise fail with "the input device is not a TTY" +// on piped stdin. +func execOpts(noTTY bool) []string { + if noTTY { + return []string{"exec", "-T"} + } + return []string{"exec"} +} + +// execArgv builds `docker compose -p <slug> exec [-T] php wp --allow-root <wpArgs...>`. +// --allow-root is required because the php container runs as root, so wp-cli +// would otherwise refuse with "YIKES! running as root". +func execArgv(r *dockercli.Runner, slug string, wpArgs []string, noTTY bool) []string { + args := append(execOpts(noTTY), phpService, "wp", "--allow-root") + args = append(args, wpArgs...) + return r.ComposeArgv(slug, args...) +} + +// defaultShellCmd is the command run when `dev-env shell` is given no explicit +// command. It mirrors Node's landoShell (vip-dev-env-shell.js -> +// dev-environment-lando.ts): prefer bash so the container's interactive profile +// (the VIP banner, colored prompt, and aliases) loads, falling back to sh for +// service containers (e.g. database) that ship no bash. The -i flag is passed +// only when interactive, matching Node's stdin.isTTY check (-i vs no flag). +func defaultShellCmd(interactive bool) []string { + flag := "" + if interactive { + flag = " -i" + } + script := fmt.Sprintf("if [ -x /bin/bash ]; then /bin/bash%s; else /bin/sh%s; fi; exit 0", flag, flag) + return []string{"/bin/sh", "-c", script} +} + +// shellArgv builds `docker compose -p <slug> exec [-T] -u <user> <service> [cmd...|default-shell]`. +func shellArgv(r *dockercli.Runner, slug, service string, root bool, cmd []string, noTTY bool) []string { + args := append(execOpts(noTTY), "-u", shellUser(service, root), service) + if len(cmd) > 0 { + args = append(args, cmd...) + } else { + args = append(args, defaultShellCmd(!noTTY)...) + } + return r.ComposeArgv(slug, args...) +} + +// Exec runs a WP-CLI command against an env. With a terminal on stdin it runs +// interactively through the raw-mode PTY; with piped stdin (e.g. redirecting +// output to a file) it runs through plain pipes. Either way output tees into +// the unified log. +func Exec(ctx context.Context, slug string, wpArgs []string) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + l, err := devlog.Open(slug) + if err != nil { + return err + } + defer l.Close() + interactive := devterm.Interactive() + argv := execArgv(r, slug, wpArgs, !interactive) + return runTermOrPipe(ctx, l, paths.EnvironmentPath(slug), argv, interactive) +} + +// Shell opens an interactive shell (or runs cmd) in a service container. Like +// Exec it falls back to plain pipes when stdin is not a terminal. +func Shell(ctx context.Context, slug, service string, root bool, cmd []string) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + l, err := devlog.Open(slug) + if err != nil { + return err + } + defer l.Close() + interactive := devterm.Interactive() + argv := shellArgv(r, slug, service, root, cmd, !interactive) + return runTermOrPipe(ctx, l, paths.EnvironmentPath(slug), argv, interactive) +} + +// runTermOrPipe dispatches a built compose-exec argv to either the raw-mode PTY +// runner (interactive) or the plain-pipe runner (non-interactive). The pipe +// path opens a separate log writer per stream, as devlog.Writer requires (its +// per-writer line buffer is single-goroutine; the underlying file is +// serialized), so the subprocess's stdout and stderr each tee into the log. +func runTermOrPipe(ctx context.Context, l *devlog.Logger, dir string, argv []string, interactive bool) error { + if interactive { + w := l.Writer() + defer w.Close() + return devterm.Run(ctx, dir, argv, w) + } + outLog := l.Writer() + defer outLog.Close() + errLog := l.Writer() + defer errLog.Close() + return devterm.RunPiped(ctx, dir, argv, + io.MultiWriter(os.Stdout, outLog), + io.MultiWriter(os.Stderr, errLog)) +} diff --git a/internal/devenv/devexec_test.go b/internal/devenv/devexec_test.go new file mode 100644 index 000000000..8b24fba14 --- /dev/null +++ b/internal/devenv/devexec_test.go @@ -0,0 +1,91 @@ +package devenv + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/dockercli" +) + +func pinnedRunner() *dockercli.Runner { + r := &dockercli.Runner{} + r.SetComposeCmdForTest([]string{"docker", "compose"}) + return r +} + +func TestExecArgv(t *testing.T) { + r := pinnedRunner() + got := execArgv(r, "myslug", []string{"post", "list", "--format=json"}, false) + want := []string{"docker", "compose", "-p", "myslug", "exec", "php", "wp", "--allow-root", "post", "list", "--format=json"} + assertArgv(t, got, want) +} + +// TestExecArgvNoTTY: when stdin is not a terminal (piped output), -T is inserted +// to disable docker compose's default pseudo-TTY allocation. +func TestExecArgvNoTTY(t *testing.T) { + r := pinnedRunner() + got := execArgv(r, "myslug", []string{"post", "list"}, true) + want := []string{"docker", "compose", "-p", "myslug", "exec", "-T", "php", "wp", "--allow-root", "post", "list"} + assertArgv(t, got, want) +} + +func TestShellArgvDefaultPHP(t *testing.T) { + r := pinnedRunner() + got := shellArgv(r, "s", "php", false, nil, false) + // No command + interactive (TTY): prefer bash with -i (Node landoShell parity), + // fall back to sh for containers without bash. + want := []string{"docker", "compose", "-p", "s", "exec", "-u", "www-data", "php", + "/bin/sh", "-c", "if [ -x /bin/bash ]; then /bin/bash -i; else /bin/sh -i; fi; exit 0"} + assertArgv(t, got, want) +} + +// TestShellArgvDefaultNonInteractive: no command + piped stdin drops the -i flag +// (matches Node's stdin.isTTY gate) and adds -T. +func TestShellArgvDefaultNonInteractive(t *testing.T) { + r := pinnedRunner() + got := shellArgv(r, "s", "php", false, nil, true) + want := []string{"docker", "compose", "-p", "s", "exec", "-T", "-u", "www-data", "php", + "/bin/sh", "-c", "if [ -x /bin/bash ]; then /bin/bash; else /bin/sh; fi; exit 0"} + assertArgv(t, got, want) +} + +func TestShellArgvRootWithCmd(t *testing.T) { + r := pinnedRunner() + got := shellArgv(r, "s", "database", true, []string{"ls", "-lha"}, false) + want := []string{"docker", "compose", "-p", "s", "exec", "-u", "root", "database", "ls", "-lha"} + assertArgv(t, got, want) +} + +// TestShellArgvNoTTY: -T precedes the -u/service options when non-interactive. +func TestShellArgvNoTTY(t *testing.T) { + r := pinnedRunner() + got := shellArgv(r, "s", "database", false, []string{"ls"}, true) + want := []string{"docker", "compose", "-p", "s", "exec", "-T", "-u", "mysql", "database", "ls"} + assertArgv(t, got, want) +} + +func TestShellUserMapping(t *testing.T) { + if shellUser("php", false) != "www-data" { + t.Fatal("php non-root should map to www-data") + } + if shellUser("database", false) != "mysql" { + t.Fatal("database non-root should map to mysql") + } + if shellUser("php", true) != "root" { + t.Fatal("root flag should force root") + } + if shellUser("unknown-svc", false) != "www-data" { + t.Fatal("unknown service should default to www-data") + } +} + +func assertArgv(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("argv = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("argv[%d] = %q, want %q (full %v)", i, got[i], want[i], got) + } + } +} diff --git a/internal/devenv/devlog/devlog.go b/internal/devenv/devlog/devlog.go new file mode 100644 index 000000000..ee2941529 --- /dev/null +++ b/internal/devenv/devlog/devlog.go @@ -0,0 +1,219 @@ +// Package devlog owns the per-environment dev-env command log. Every +// docker/docker compose invocation tees its output here via Writer(), and the +// CLI's own dev-env diagnostics are logged through Logf, so VIP and Docker +// output interleave in one per-invocation timestamped file — the behavior +// Lando's winston logger + shell tee provided before. Each invocation opens a +// fresh file under the environment's own logs/ directory (Node parity: +// getDevEnvLogFile -> vip-dev-env-<slug>-<timestamp>.log). +package devlog + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +const logName = "vip-dev-env" + +// Logger is a single open handle to one invocation's log file. Safe for +// concurrent writers (the tee from stdout and stderr run concurrently). +type Logger struct { + mu sync.Mutex + f *os.File + path string + tty io.Writer // where Finish() prints the log-path footer +} + +// Open creates the environment's logs/ directory and opens a fresh, +// per-invocation timestamped log file for appending. +func Open(slug string) (*Logger, error) { + dir := paths.EnvLogDir(slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + p := filepath.Join(dir, logFileName(slug, time.Now())) + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, err + } + return &Logger{f: f, path: p, tty: os.Stderr}, nil +} + +// logFileName builds vip-dev-env-<slug>-<timestamp>.log, matching Node's +// getDevEnvLogFile (formatDevEnvLogSlug + formatDevEnvLogTimestamp). +func logFileName(slug string, t time.Time) string { + return fmt.Sprintf("%s-%s-%s.log", logName, formatLogSlug(slug), t.UTC().Format("20060102-150405")) +} + +var logSlugInvalid = regexp.MustCompile(`[^a-z0-9_-]+`) + +// formatLogSlug mirrors Node's formatDevEnvLogSlug: lowercase, replacing any +// run of disallowed characters with a single dash. An empty slug maps to "all". +func formatLogSlug(slug string) string { + if slug == "" { + return "all" + } + return logSlugInvalid.ReplaceAllString(strings.ToLower(slug), "-") +} + +// Path returns the log file path. +func (l *Logger) Path() string { return l.path } + +// Close closes the underlying file. +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.f.Close() +} + +// writeLine writes one prefixed line (no trailing newline in msg). +func (l *Logger) writeLine(level, msg string) { + l.mu.Lock() + defer l.mu.Unlock() + ts := time.Now().UTC().Format("2006-01-02T15:04:05Z") + _, _ = fmt.Fprintf(l.f, "%s [%s] %s: %s\n", ts, logName, level, msg) +} + +// Logf writes a single diagnostic line at INFO level. +func (l *Logger) Logf(format string, args ...any) { + l.writeLine("INFO", fmt.Sprintf(format, args...)) +} + +// Writer returns an io.WriteCloser that splits input into lines and writes +// each complete line to the log with a prefix. A trailing partial line is +// held in a buffer and flushed when the writer is Closed — callers MUST +// Close the writer (after the subprocess exits) so a final non-newline- +// terminated line is not lost. +// +// Each returned writer is single-goroutine: its internal line buffer is not +// synchronized, so call Writer() once per concurrent stream (e.g. separate +// writers for a subprocess's stdout and stderr) rather than sharing one +// writer across goroutines. Writes to the underlying log file are serialized. +func (l *Logger) Writer() io.WriteCloser { + return &lineWriter{log: l, level: "INFO"} +} + +// DockerVersions holds the docker/compose versions shown in the banner. +type DockerVersions struct { + Engine, Compose, ComposePlugin, DockerBin, ComposeBin string +} + +// Banner is the diagnostic header written once at the top of a fresh log. +// Ports writeLogBanner (dev-environment-lando.ts:247-286); NODE is replaced +// by CLI/runtime since there is no Node runtime any more. +type Banner struct { + Command string + OS string + CLI string + Runtime string + Docker DockerVersions + RAMGB string + CPUs string +} + +// WriteBanner appends the banner only if the log file is currently empty, +// matching Lando's "write banner when size == 0" behavior. +func (l *Logger) WriteBanner(b Banner) error { + l.mu.Lock() + defer l.mu.Unlock() + + info, err := l.f.Stat() + if err != nil { + return err + } + if info.Size() > 0 { + return nil + } + + line := func(label, value string) string { + return fmt.Sprintf("%-18s %s\n", label, value) + } + var sb []byte + sb = append(sb, "=== VIP Dev Env Log ===\n"...) + sb = append(sb, line("COMMAND", b.Command)...) + sb = append(sb, line("OS", b.OS)...) + sb = append(sb, line("CLI", b.CLI)...) + sb = append(sb, line("RUNTIME", b.Runtime)...) + sb = append(sb, line("DOCKER ENGINE", b.Docker.Engine)...) + sb = append(sb, line("DOCKER COMPOSE", b.Docker.Compose)...) + sb = append(sb, line("COMPOSE PLUGIN", b.Docker.ComposePlugin)...) + sb = append(sb, line("DOCKER BIN", b.Docker.DockerBin)...) + sb = append(sb, line("COMPOSE BIN", b.Docker.ComposeBin)...) + sb = append(sb, line("RAM", b.RAMGB)...) + sb = append(sb, line("CPU", b.CPUs)...) + sb = append(sb, "===\n\n\n"...) + + _, err = l.f.Write(sb) + return err +} + +type lineWriter struct { + log *Logger + level string + buf bytes.Buffer +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + line, err := w.buf.ReadString('\n') + if err != nil { + // No newline yet: put the partial back and wait for more. + w.buf.Reset() + w.buf.WriteString(line) + break + } + w.log.writeLine(w.level, strings.TrimRight(line[:len(line)-1], "\r")) + } + return len(p), nil +} + +// Close flushes any buffered partial (non-newline-terminated) line to the +// log so trailing output is never dropped. +func (w *lineWriter) Close() error { + if w.buf.Len() > 0 { + w.log.writeLine(w.level, w.buf.String()) + w.buf.Reset() + } + return nil +} + +// SetFooterWriter overrides where Finish() writes (defaults to stderr). +// Used in tests; production passes os.Stderr. +func (l *Logger) SetFooterWriter(w io.Writer) { + l.mu.Lock() + defer l.mu.Unlock() + l.tty = w +} + +// Finish prints the "COMMAND LOG FILE <path>" footer so users can find the +// combined log. Ports registerLogPathOutput (dev-environment-lando.ts:146-171). +func (l *Logger) Finish() { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Fprintf(l.tty, "\n %-18s %s\n", "COMMAND LOG FILE", l.path) +} + +// loggerCtxKey is the private context key under which a session Logger is +// carried so the docker runner can pick it up without signature churn. +type loggerCtxKey struct{} + +// WithLogger returns a context carrying l, so newRunner can tee through it. +func WithLogger(ctx context.Context, l *Logger) context.Context { + return context.WithValue(ctx, loggerCtxKey{}, l) +} + +// FromContext returns the session Logger carried by ctx, or nil. +func FromContext(ctx context.Context) *Logger { + l, _ := ctx.Value(loggerCtxKey{}).(*Logger) + return l +} diff --git a/internal/devenv/devlog/devlog_test.go b/internal/devenv/devlog/devlog_test.go new file mode 100644 index 000000000..adbbc18f4 --- /dev/null +++ b/internal/devenv/devlog/devlog_test.go @@ -0,0 +1,112 @@ +package devlog + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestWriterFlushesPartialLineOnClose(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := Open("testslug") + if err != nil { + t.Fatalf("Open: %v", err) + } + w := l.Writer() + w.Write([]byte("no newline at end")) + if err := w.Close(); err != nil { + t.Fatalf("Writer Close: %v", err) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + b, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "no newline at end") { + t.Fatalf("partial line lost; log:\n%s", b) + } +} + +func TestWriterPrefixesCompleteLinesIntoLogFile(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + l, err := Open("testslug") + if err != nil { + t.Fatalf("Open: %v", err) + } + w := l.Writer() + // Two writes that together form exactly two complete lines split across + // the write boundary ("hello\n" and "world\n"). No partial remains. + w.Write([]byte("hello\nwor")) + w.Write([]byte("ld\n")) + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + got := string(b) + if c := strings.Count(got, "[vip-dev-env] INFO:"); c != 2 { + t.Fatalf("expected 2 prefixed lines, got %d in:\n%s", c, got) + } + if !strings.Contains(got, "hello") || !strings.Contains(got, "world") { + t.Fatalf("log missing content:\n%s", got) + } +} + +func TestWriteBannerOnlyOnEmptyLog(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + b := Banner{ + Command: "vip dev-env start", + OS: "darwin 25.5.0 arm64", + CLI: "4.0.0", + Runtime: "go", + Docker: DockerVersions{Engine: "27.0", Compose: "2.29", ComposePlugin: "2.29", DockerBin: "/usr/bin/docker", ComposeBin: "docker compose"}, + RAMGB: "16.0 GB", + CPUs: "10", + } + + l, _ := Open("testslug") + if err := l.WriteBanner(b); err != nil { + t.Fatalf("WriteBanner: %v", err) + } + // Second call must be a no-op because the file is no longer empty. + if err := l.WriteBanner(b); err != nil { + t.Fatalf("WriteBanner (2nd): %v", err) + } + l.Close() + + data, _ := os.ReadFile(l.Path()) + got := string(data) + if c := strings.Count(got, "=== VIP Dev Env Log ==="); c != 1 { + t.Fatalf("banner written %d times, want 1:\n%s", c, got) + } + for _, want := range []string{"COMMAND", "DOCKER ENGINE", "27.0", "vip dev-env start"} { + if !strings.Contains(got, want) { + t.Fatalf("banner missing %q:\n%s", want, got) + } + } +} + +func TestFinishPrintsLogPathFooter(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, _ := Open("testslug") + + var tty bytes.Buffer + l.SetFooterWriter(&tty) + l.Finish() + l.Close() + + if !strings.Contains(tty.String(), "COMMAND LOG FILE") { + t.Fatalf("footer missing label: %q", tty.String()) + } + if !strings.Contains(tty.String(), l.Path()) { + t.Fatalf("footer missing path %q in %q", l.Path(), tty.String()) + } +} diff --git a/internal/devenv/devterm/devterm.go b/internal/devenv/devterm/devterm.go new file mode 100644 index 000000000..9d99f0ab6 --- /dev/null +++ b/internal/devenv/devterm/devterm.go @@ -0,0 +1,65 @@ +// Package devterm runs an interactive child process attached to a PTY and tees +// the PTY master to both the terminal and the unified dev-env log (spec §7.3). +// It is the §C "sharp edge" isolated here and reused by exec + shell. +// +// This file holds the cross-platform core: argv handling, the TTY check +// (Interactive), and the non-interactive RunPiped path (plain pipes, no PTY). +// The interactive raw-mode PTY run lives in devterm_pty.go (built on every +// non-Windows platform); Windows gets devterm_stub.go because that path needs +// Unix-only primitives (creack/pty, SIGWINCH, raw-mode termios). +package devterm + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + + "golang.org/x/term" +) + +// splitArgv splits a non-empty argv into the binary name and its arguments. +func splitArgv(argv []string) (string, []string) { + return argv[0], argv[1:] +} + +// safeSplit is splitArgv with an empty-argv guard, used by Run. +func safeSplit(argv []string) (string, []string, error) { + if len(argv) == 0 { + return "", nil, errors.New("devterm: empty argv") + } + name, rest := splitArgv(argv) + return name, rest, nil +} + +// Interactive reports whether stdin is a terminal. Callers use it to choose +// between the raw-mode PTY path (Run) and the plain-pipe path (RunPiped), and +// to decide whether to disable docker compose's default TTY allocation. This +// mirrors Node's dev-env, which gates interactivity on process.stdin.isTTY +// (vip-dev-env-shell.js / dev-environment-lando.ts landoShell). +func Interactive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// RunPiped runs argv with inherited stdin and the caller-provided stdout/stderr +// writers (which devexec wires to MultiWriter(os.Stdout, log) and +// MultiWriter(os.Stderr, log) so output both reaches the terminal and tees to +// the unified dev-env log). Unlike Run it allocates no PTY and touches no raw +// terminal state, so it works when stdout/stdin are pipes (e.g. +// `vip dev-env exec -- wp post list --format=json > out.json`) and on every +// platform, Windows included. When dir is non-empty the child runs there. +func RunPiped(ctx context.Context, dir string, argv []string, stdout, stderr io.Writer) error { + name, rest, err := safeSplit(argv) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, name, rest...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} diff --git a/internal/devenv/devterm/devterm_pty.go b/internal/devenv/devterm/devterm_pty.go new file mode 100644 index 000000000..5b0244756 --- /dev/null +++ b/internal/devenv/devterm/devterm_pty.go @@ -0,0 +1,58 @@ +//go:build !windows + +package devterm + +import ( + "context" + "io" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/creack/pty" + "golang.org/x/term" +) + +// Run starts argv attached to a PTY, puts the host terminal in raw mode, copies +// stdin->pty and pty->MultiWriter(os.Stdout, logW), handles SIGWINCH, and +// restores the terminal on exit. The unified-log tee falls out of the +// MultiWriter. When dir is non-empty the child runs with that working directory +// (the env's materialized dir so `docker compose exec` finds its compose file). +// Built on every non-Windows platform; needs a controlling TTY at runtime +// (term.MakeRaw errors cleanly if stdin is not a terminal). +func Run(ctx context.Context, dir string, argv []string, logW io.Writer) error { + name, rest, err := safeSplit(argv) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, name, rest...) + if dir != "" { + cmd.Dir = dir + } + ptmx, err := pty.Start(cmd) + if err != nil { + return err + } + defer func() { _ = ptmx.Close() }() + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + _ = pty.InheritSize(os.Stdin, ptmx) + } + }() + ch <- syscall.SIGWINCH // initial sizing + defer signal.Stop(ch) + + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return err + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() + + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + _, _ = io.Copy(io.MultiWriter(os.Stdout, logW), ptmx) + return cmd.Wait() +} diff --git a/internal/devenv/devterm/devterm_stub.go b/internal/devenv/devterm/devterm_stub.go new file mode 100644 index 000000000..3643ab167 --- /dev/null +++ b/internal/devenv/devterm/devterm_stub.go @@ -0,0 +1,20 @@ +//go:build windows + +package devterm + +import ( + "context" + "errors" + "io" +) + +// Run (stub) — the real raw-mode PTY implementation lives in devterm_pty.go and +// is built on every non-Windows platform. Windows is excluded because the PTY +// path depends on Unix-only primitives (creack/pty, SIGWINCH, raw-mode termios), +// so it returns a clear unsupported error here. +func Run(_ context.Context, _ string, argv []string, _ io.Writer) error { + if _, _, err := safeSplit(argv); err != nil { + return err + } + return errors.New("devterm: interactive exec/shell is not supported on Windows") +} diff --git a/internal/devenv/devterm/devterm_test.go b/internal/devenv/devterm/devterm_test.go new file mode 100644 index 000000000..d31a092c7 --- /dev/null +++ b/internal/devenv/devterm/devterm_test.go @@ -0,0 +1,78 @@ +package devterm + +import ( + "bytes" + "context" + "runtime" + "strings" + "testing" +) + +// TestRunPipedTeesStdout runs a harmless command and verifies stdout is written +// to the caller-provided stdout writer (devexec wires this to MultiWriter(os.Stdout, log)). +func TestRunPipedTeesStdout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (echo/sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"echo", "hello-piped"}, &out, &errb); err != nil { + t.Fatalf("RunPiped: %v", err) + } + if got := strings.TrimSpace(out.String()); got != "hello-piped" { + t.Fatalf("stdout = %q, want hello-piped", got) + } +} + +// TestRunPipedTeesStderr verifies stderr goes to the stderr writer, not stdout. +func TestRunPipedTeesStderr(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (echo/sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"sh", "-c", "echo oops 1>&2"}, &out, &errb); err != nil { + t.Fatalf("RunPiped: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout = %q, want empty", out.String()) + } + if got := strings.TrimSpace(errb.String()); got != "oops" { + t.Fatalf("stderr = %q, want oops", got) + } +} + +// TestRunPipedPropagatesExit verifies a non-zero child exit surfaces as an error +// (so the CLI can set a non-zero exit code, like Node's process.exitCode = 1). +func TestRunPipedPropagatesExit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"sh", "-c", "exit 3"}, &out, &errb); err == nil { + t.Fatal("expected error for non-zero exit") + } +} + +// TestRunPipedEmptyArgv guards the empty-argv path. +func TestRunPipedEmptyArgv(t *testing.T) { + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", nil, &out, &errb); err == nil { + t.Fatal("expected error for empty argv") + } +} + +func TestSplitArgvForExec(t *testing.T) { + argv := []string{"docker", "compose", "-p", "x", "exec", "php", "sh"} + name, rest := splitArgv(argv) + if name != "docker" { + t.Fatalf("name = %q, want docker", name) + } + if len(rest) != 6 || rest[0] != "compose" || rest[5] != "sh" { + t.Fatalf("rest = %v", rest) + } +} + +func TestSplitArgvEmpty(t *testing.T) { + if _, _, err := safeSplit(nil); err == nil { + t.Fatal("expected error for empty argv") + } +} diff --git a/internal/devenv/dockercli/capture.go b/internal/devenv/dockercli/capture.go new file mode 100644 index 000000000..bd16e76f5 --- /dev/null +++ b/internal/devenv/dockercli/capture.go @@ -0,0 +1,84 @@ +package dockercli + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// ServiceState is the subset of `docker compose ps --format json` we consume. +type ServiceState struct { + Service string `json:"Service"` + State string `json:"State"` + ExitCode int `json:"ExitCode"` +} + +// parseComposePS handles both NDJSON (one object per line) and a JSON array, +// which different compose versions emit. Blank input yields no services. +func parseComposePS(b []byte) ([]ServiceState, error) { + t := bytes.TrimSpace(b) + if len(t) == 0 { + return nil, nil + } + if t[0] == '[' { + var arr []ServiceState + if err := json.Unmarshal(t, &arr); err != nil { + return nil, err + } + return arr, nil + } + var out []ServiceState + for _, line := range strings.Split(string(t), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var s ServiceState + if err := json.Unmarshal([]byte(line), &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +// DockerOut runs `docker <args...>` capturing stdout (no tee). For read-only +// queries like `volume ls`. stderr is discarded; the error carries exit status. +func (r *Runner) DockerOut(ctx context.Context, args ...string) ([]byte, error) { + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, r.dockerBin(), args...) + cmd.Stdout = &buf + err := cmd.Run() + return buf.Bytes(), err +} + +// ComposeOut runs a compose subcommand scoped to a project from the project's +// materialized directory (so compose finds its docker-compose.yml) and returns +// captured stdout. For read-only queries like `ps -q <service>`. +func (r *Runner) ComposeOut(ctx context.Context, project string, args ...string) ([]byte, error) { + inv := r.composeInv() + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, inv[0], r.ComposeArgs(project, args...)...) + cmd.Dir = paths.EnvironmentPath(project) + cmd.Stdout = &buf + err := cmd.Run() + return buf.Bytes(), err +} + +// ComposePS returns parsed service states for a project (captured, not tee'd). +// It runs from the project's materialized directory so compose finds its file. +func (r *Runner) ComposePS(ctx context.Context, project string) ([]ServiceState, error) { + inv := r.composeInv() + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, inv[0], r.ComposeArgs(project, "ps", "--format", "json", "--all")...) + cmd.Dir = paths.EnvironmentPath(project) + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, err + } + return parseComposePS(buf.Bytes()) +} diff --git a/internal/devenv/dockercli/capture_test.go b/internal/devenv/dockercli/capture_test.go new file mode 100644 index 000000000..a81d04385 --- /dev/null +++ b/internal/devenv/dockercli/capture_test.go @@ -0,0 +1,33 @@ +package dockercli + +import "testing" + +func TestParseComposePSNDJSON(t *testing.T) { + in := []byte(`{"Service":"wordpress","State":"exited","ExitCode":0} +{"Service":"php","State":"running","ExitCode":0}`) + got, err := parseComposePS(in) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Service != "wordpress" || got[0].State != "exited" || got[0].ExitCode != 0 { + t.Fatalf("bad parse: %+v", got) + } + if got[1].Service != "php" || got[1].State != "running" { + t.Fatalf("bad parse: %+v", got) + } +} + +func TestParseComposePSArray(t *testing.T) { + in := []byte(`[{"Service":"db","State":"running","ExitCode":0}]`) + got, err := parseComposePS(in) + if err != nil || len(got) != 1 || got[0].Service != "db" { + t.Fatalf("array parse failed: %+v err=%v", got, err) + } +} + +func TestParseComposePSEmpty(t *testing.T) { + got, err := parseComposePS([]byte(" \n")) + if err != nil || len(got) != 0 { + t.Fatalf("empty should yield no services: %+v err=%v", got, err) + } +} diff --git a/internal/devenv/dockercli/compose.go b/internal/devenv/dockercli/compose.go new file mode 100644 index 000000000..49a276410 --- /dev/null +++ b/internal/devenv/dockercli/compose.go @@ -0,0 +1,30 @@ +package dockercli + +import ( + "os/exec" +) + +// composeInvocation decides how to invoke Compose: the `docker compose` plugin +// (preferred) or the standalone `docker-compose` binary. look mirrors +// exec.LookPath; pluginOK reports whether `<dockerBin> compose version` works. +func composeInvocation(dockerBin string, look func(string) (string, error), pluginOK func() bool) []string { + if pluginOK() { + return []string{dockerBin, "compose"} + } + if _, err := look("docker-compose"); err == nil { + return []string{"docker-compose"} + } + return []string{dockerBin, "compose"} // default; exec surfaces the real error +} + +// composeInv caches the resolved invocation per runner. +func (r *Runner) composeInv() []string { + r.composeOnce.Do(func() { + r.composeCmd = composeInvocation(r.dockerBin(), + exec.LookPath, + func() bool { + return exec.Command(r.dockerBin(), "compose", "version").Run() == nil + }) + }) + return r.composeCmd +} diff --git a/internal/devenv/dockercli/compose_test.go b/internal/devenv/dockercli/compose_test.go new file mode 100644 index 000000000..18f0b0b66 --- /dev/null +++ b/internal/devenv/dockercli/compose_test.go @@ -0,0 +1,35 @@ +package dockercli + +import ( + "errors" + "testing" +) + +// errNotFound is a sentinel the lookPath stubs below return for an absent binary. +var errNotFound = errors.New("executable not found") + +func TestComposeInvocationPluginPreferred(t *testing.T) { + inv := composeInvocation("docker", func(string) (string, error) { return "/x", nil }, func() bool { return true }) + if len(inv) != 2 || inv[0] != "docker" || inv[1] != "compose" { + t.Fatalf("want [docker compose], got %v", inv) + } +} + +func TestComposeInvocationStandaloneFallback(t *testing.T) { + inv := composeInvocation("docker", func(name string) (string, error) { + if name == "docker-compose" { + return "/usr/local/bin/docker-compose", nil + } + return "", errNotFound + }, func() bool { return false }) + if len(inv) != 1 || inv[0] != "docker-compose" { + t.Fatalf("want [docker-compose], got %v", inv) + } +} + +func TestComposeInvocationDefaultsToPlugin(t *testing.T) { + inv := composeInvocation("docker", func(string) (string, error) { return "", errNotFound }, func() bool { return false }) + if len(inv) != 2 || inv[0] != "docker" || inv[1] != "compose" { + t.Fatalf("want [docker compose] default, got %v", inv) + } +} diff --git a/internal/devenv/dockercli/runner.go b/internal/devenv/dockercli/runner.go new file mode 100644 index 000000000..a03886a6b --- /dev/null +++ b/internal/devenv/dockercli/runner.go @@ -0,0 +1,214 @@ +package dockercli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/Automattic/vip/internal/devenv/devlog" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// Runner executes docker / docker compose commands, tee-ing child stdout and +// stderr to both the terminal and the unified log (spec §7.3). It is the Go +// replacement for Lando's Shell.sh tee + command/exit-code trace. +type Runner struct { + Log *devlog.Logger + Stdout io.Writer // defaults to os.Stdout + Stderr io.Writer // defaults to os.Stderr + DockerBin string // docker executable; defaults to "docker" + composeOnce sync.Once + composeCmd []string +} + +// lockedWriter wraps an io.Writer with a shared mutex pointer so multiple +// lockedWriter instances covering the same underlying writer (e.g. when +// Stdout and Stderr both point to the same bytes.Buffer) share one lock. +// os/exec drives cmd.Stdout and cmd.Stderr from separate goroutines, so +// without the lock concurrent writes to a non-goroutine-safe writer race. +type lockedWriter struct { + mu *sync.Mutex + w io.Writer +} + +func (lw *lockedWriter) Write(p []byte) (int, error) { + lw.mu.Lock() + defer lw.mu.Unlock() + return lw.w.Write(p) +} + +func (r *Runner) out() io.Writer { + if r.Stdout != nil { + return r.Stdout + } + return os.Stdout +} + +func (r *Runner) err() io.Writer { + if r.Stderr != nil { + return r.Stderr + } + return os.Stderr +} + +// dockerBin returns the configured docker binary or the default. +func (r *Runner) dockerBin() string { + if r.DockerBin != "" { + return r.DockerBin + } + return "docker" +} + +// ComposeArgs builds the argument list (minus the leading binary) for a +// compose invocation scoped to a project. For the plugin form the list begins +// with "compose"; for the standalone form it begins with "-p" directly. +func (r *Runner) ComposeArgs(project string, args ...string) []string { + inv := r.composeInv() + out := append([]string{}, inv[1:]...) // "compose" for plugin, nothing for standalone + out = append(out, "-p", project) + return append(out, args...) +} + +// ComposeArgv builds the FULL argv (including the leading docker/compose +// binary) for a compose invocation scoped to a project. Unlike ComposeArgs +// (which omits the binary because Runner.run supplies it), this is for callers +// that hand a complete argv to another exec mechanism — the PTY tee in +// internal/devenv/devterm for interactive exec/shell. +func (r *Runner) ComposeArgv(project string, args ...string) []string { + inv := r.composeInv() + out := append([]string{}, inv...) // binary (+ "compose" for the plugin form) + out = append(out, "-p", project) + return append(out, args...) +} + +// SetComposeCmdForTest pins the resolved compose invocation. Test-only seam so +// other packages can build deterministic argv without a real docker install. +func (r *Runner) SetComposeCmdForTest(inv []string) { + r.composeCmd = inv + r.composeOnce.Do(func() {}) +} + +// Docker runs `docker <args...>`. +func (r *Runner) Docker(ctx context.Context, args ...string) error { + return r.run(ctx, "", r.dockerBin(), args...) +} + +// Compose runs the resolved compose binary scoped to a project, executing from +// the project's materialized directory so docker compose finds its +// docker-compose.yml (default discovery) and resolves the relative bind-mount +// paths (./config, ./uploads, .env, ...) against that directory. +func (r *Runner) Compose(ctx context.Context, project string, args ...string) error { + inv := r.composeInv() + return r.run(ctx, paths.EnvironmentPath(project), inv[0], r.ComposeArgs(project, args...)...) +} + +// ComposeStdin runs a compose command scoped to a project with stdin streamed +// from r (used to pipe a SQL dump into `wp db-myloader --stream`). Output is +// tee'd like Compose. +func (r *Runner) ComposeStdin(ctx context.Context, project string, stdin io.Reader, args ...string) error { + inv := r.composeInv() + return r.runStdin(ctx, paths.EnvironmentPath(project), stdin, inv[0], r.ComposeArgs(project, args...)...) +} + +// Versions probes docker/compose versions for the log banner. Failures are +// reported as "unknown" rather than errors (ports getDockerVersions, +// dev-environment-lando.ts:197-242). Output is captured, not tee'd. +func (r *Runner) Versions(ctx context.Context) devlog.DockerVersions { + inv := r.composeInv() + v := devlog.DockerVersions{ + Engine: "unknown", Compose: "unknown", ComposePlugin: "unknown", + DockerBin: r.dockerBin(), ComposeBin: strings.Join(inv, " "), + } + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, r.dockerBin(), "info", "--format", "{{.ServerVersion}}") + cmd.Stdout = &buf + if err := cmd.Run(); err == nil { + if s := strings.TrimSpace(buf.String()); s != "" { + v.Engine = s + } + } + buf.Reset() + cmd = exec.CommandContext(ctx, inv[0], append(append([]string{}, inv[1:]...), "version", "--short")...) + cmd.Stdout = &buf + if err := cmd.Run(); err == nil { + if s := strings.TrimSpace(buf.String()); s != "" { + v.Compose = s + v.ComposePlugin = s + } + } + return v +} + +// run executes a single command, tee-ing output to the terminal and the log +// and recording the command line + exit code. When dir is non-empty the child +// runs with that working directory (compose commands run from the env's +// materialized dir so docker compose finds its compose file). The tee writers +// are closed after the process exits so devlog flushes any buffered trailing +// partial line (docker output that ends without a newline). +func (r *Runner) run(ctx context.Context, dir, name string, args ...string) error { + return r.runStdin(ctx, dir, nil, name, args...) +} + +// runStdin is run() with an optional stdin source (used to stream a SQL dump +// into `docker compose exec -T … wp db-myloader --stream`). When stdin is nil +// it behaves exactly like run(). +func (r *Runner) runStdin(ctx context.Context, dir string, stdin io.Reader, name string, args ...string) error { + if r.Log != nil { + r.Log.Logf("running: %s %s", name, strings.Join(args, " ")) + } + + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + if stdin != nil { + cmd.Stdin = stdin + } + + // Wrap the terminal writers behind a shared mutex. os/exec drives + // cmd.Stdout and cmd.Stderr from separate goroutines; if both point to + // the same underlying writer (common in tests and when output is + // redirected) concurrent writes race. One shared mutex covers both. + termMu := &sync.Mutex{} + termOut := &lockedWriter{mu: termMu, w: r.out()} + termErr := &lockedWriter{mu: termMu, w: r.err()} + + var outTee, errTee io.WriteCloser + if r.Log != nil { + outTee = r.Log.Writer() + errTee = r.Log.Writer() + cmd.Stdout = io.MultiWriter(termOut, outTee) + cmd.Stderr = io.MultiWriter(termErr, errTee) + } else { + cmd.Stdout = termOut + cmd.Stderr = termErr + } + + runErr := cmd.Run() + + // Flush buffered trailing partial lines into the log BEFORE recording the + // exit code, so all command output precedes the "finished" line. + if outTee != nil { + _ = outTee.Close() + _ = errTee.Close() + } + + code := 0 + if runErr != nil { + var ee *exec.ExitError + if errors.As(runErr, &ee) { + code = ee.ExitCode() + } else { + code = -1 + } + } + if r.Log != nil { + r.Log.Logf("finished: %s, exit code %d", name, code) + } + return runErr +} diff --git a/internal/devenv/dockercli/runner_test.go b/internal/devenv/dockercli/runner_test.go new file mode 100644 index 000000000..7cdb149dd --- /dev/null +++ b/internal/devenv/dockercli/runner_test.go @@ -0,0 +1,157 @@ +package dockercli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/devlog" +) + +// TestRunHonorsDir proves the child process executes in the working directory +// passed to run — the fix for "no configuration file provided" (compose must +// run from the env's materialized dir, not the CLI's CWD). +func TestRunHonorsDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/pwd, /bin/sh)") + } + dir := t.TempDir() + var out bytes.Buffer + r := &Runner{Stdout: &out, Stderr: &out} + if err := r.run(context.Background(), dir, "/bin/pwd"); err != nil { + t.Fatalf("run: %v", err) + } + got, err := filepath.EvalSymlinks(strings.TrimSpace(out.String())) + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(dir) + if got != want { + t.Fatalf("run executed in %q, want %q", got, want) + } +} + +func TestRunTeesStdoutToTerminalAndLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/sh)") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := devlog.Open("testslug") + if err != nil { + t.Fatal(err) + } + + var term bytes.Buffer + r := &Runner{Log: l, Stdout: &term, Stderr: &term} + + // Use /bin/sh so the test does not require docker to be installed. + if err := r.run(context.Background(), "", "/bin/sh", "-c", "echo out-line; echo err-line 1>&2"); err != nil { + t.Fatalf("run: %v", err) + } + l.Close() + + if !strings.Contains(term.String(), "out-line") || !strings.Contains(term.String(), "err-line") { + t.Fatalf("terminal capture missing output: %q", term.String()) + } + + logBytes, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + logStr := string(logBytes) + if !strings.Contains(logStr, "out-line") || !strings.Contains(logStr, "err-line") { + t.Fatalf("log missing tee'd output:\n%s", logStr) + } + if !strings.Contains(logStr, "running:") { + t.Fatalf("log missing command trace:\n%s", logStr) + } + if !strings.Contains(logStr, "exit code 0") { + t.Fatalf("log missing exit code:\n%s", logStr) + } +} + +// TestRunFlushesTrailingPartialLineToLog proves the Runner closes the tee +// writers after the subprocess exits, so a final line WITHOUT a trailing +// newline is still captured in the log (devlog.Writer() only flushes its +// buffered partial line on Close). +func TestRunFlushesTrailingPartialLineToLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/sh)") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := devlog.Open("testslug") + if err != nil { + t.Fatal(err) + } + var term bytes.Buffer + r := &Runner{Log: l, Stdout: &term, Stderr: &term} + if err := r.run(context.Background(), "", "/bin/sh", "-c", "printf 'no-trailing-newline'"); err != nil { + t.Fatalf("run: %v", err) + } + l.Close() + logBytes, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logBytes), "no-trailing-newline") { + t.Fatalf("trailing partial line lost in log:\n%s", logBytes) + } +} + +func TestComposeArgsPrependsComposeSubcommandAndProject(t *testing.T) { + // Pin the compose invocation to the plugin form so the test is not + // sensitive to whether docker compose is installed on the host. + r := &Runner{} + r.composeCmd = []string{"docker", "compose"} + r.composeOnce.Do(func() {}) // mark as done so composeInv() uses the pinned value + + got := r.ComposeArgs("myproject", "up", "-d") + want := []string{"compose", "-p", "myproject", "up", "-d"} + if len(got) != len(want) { + t.Fatalf("ComposeArgs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ComposeArgs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestComposeArgvIncludesBinaryAndProject(t *testing.T) { + r := &Runner{} + r.composeCmd = []string{"docker", "compose"} + r.composeOnce.Do(func() {}) + got := r.ComposeArgv("proj", "exec", "php", "sh") + want := []string{"docker", "compose", "-p", "proj", "exec", "php", "sh"} + if len(got) != len(want) { + t.Fatalf("ComposeArgv = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ComposeArgv[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestVersionsDegradesGracefullyWhenDockerMissing(t *testing.T) { + // Pin the compose invocation to the plugin form using a nonexistent binary + // so both the engine probe AND the compose probe fail deterministically, + // regardless of whether docker-compose standalone is installed on the host. + r := &Runner{DockerBin: "definitely-not-a-real-docker-binary-xyz"} + r.composeCmd = []string{"definitely-not-a-real-docker-binary-xyz", "compose"} + r.composeOnce.Do(func() {}) // mark as done so composeInv() uses the pinned value + v := r.Versions(context.Background()) + if v.Engine != "unknown" || v.Compose != "unknown" || v.ComposePlugin != "unknown" { + t.Fatalf("expected unknown versions when docker is missing, got %+v", v) + } + if v.DockerBin != "definitely-not-a-real-docker-binary-xyz" { + t.Fatalf("DockerBin not reflected: %q", v.DockerBin) + } + if v.ComposeBin != "definitely-not-a-real-docker-binary-xyz compose" { + t.Fatalf("ComposeBin not as expected: %q", v.ComposeBin) + } +} diff --git a/internal/devenv/dockercli/socket.go b/internal/devenv/dockercli/socket.go new file mode 100644 index 000000000..e243cf118 --- /dev/null +++ b/internal/devenv/dockercli/socket.go @@ -0,0 +1,56 @@ +// Package dockercli drives the docker and docker compose CLIs (spec §4). +// "Purely Go" here means no Node/Lando — we still shell out to the docker +// binaries, which are already hard host requirements. +package dockercli + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// DockerSocket ports getDockerSocket (docker-utils.ts:45-82). On non-Windows +// it resolves a usable unix socket path, honoring a non-unix DOCKER_HOST +// verbatim. Returns "" (no error) when nothing usable is found. +// +// SIDE EFFECT (intentional, mirrors the Node helper): when a usable unix +// socket is discovered it also sets DOCKER_HOST=unix://<path> in the process +// environment so child `docker` invocations inherit it. Call once at startup. +func DockerSocket() (string, error) { + if runtime.GOOS == "windows" { + return "", nil + } + + possible := os.Getenv("DOCKER_HOST") + if possible != "" && !strings.HasPrefix(possible, "unix://") { + return possible, nil + } + + var candidates []string + if possible != "" { + // Strip leading unix:// (may have 1-3 slashes) and normalize to /path. + trimmed := strings.TrimLeft(strings.TrimPrefix(possible, "unix:"), "/") + candidates = append(candidates, "/"+trimmed) + } + home, _ := os.UserHomeDir() + candidates = append(candidates, + "/var/run/docker.sock", + "/run/docker.sock", + filepath.Join(home, ".docker", "run", "docker.sock"), + filepath.Join(home, ".colima", "default", "docker.sock"), + filepath.Join(home, ".orbstack", "run", "docker.sock"), + ) + + for _, p := range candidates { + info, err := os.Stat(p) + if err != nil { + continue + } + if info.Mode()&os.ModeSocket != 0 { + os.Setenv("DOCKER_HOST", "unix://"+p) + return p, nil + } + } + return "", nil +} diff --git a/internal/devenv/dockercli/socket_test.go b/internal/devenv/dockercli/socket_test.go new file mode 100644 index 000000000..678019925 --- /dev/null +++ b/internal/devenv/dockercli/socket_test.go @@ -0,0 +1,52 @@ +package dockercli + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +func TestDockerSocketHonorsNonUnixDockerHost(t *testing.T) { + t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:2375") + got, err := DockerSocket() + if err != nil { + t.Fatalf("DockerSocket: %v", err) + } + if got != "tcp://127.0.0.1:2375" { + t.Fatalf("got %q, want the tcp DOCKER_HOST passed through", got) + } +} + +func TestDockerSocketFindsUnixSocket(t *testing.T) { + // Use a short base dir under /tmp rather than t.TempDir(): macOS limits + // unix socket paths to ~104 bytes and t.TempDir() under $TMPDIR + // (/var/folders/...) overflows it, which would silently skip this test + // on the project's primary target platform. /tmp keeps the path short on + // both macOS and Linux so the discovery + slash-normalization logic is + // actually exercised. + dir, err := os.MkdirTemp("/tmp", "ds") + if err != nil { + t.Skipf("cannot create short temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + sockPath := filepath.Join(dir, "d.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Skipf("cannot create unix socket: %v", err) + } + defer ln.Close() + + t.Setenv("DOCKER_HOST", "unix://"+sockPath) + got, err := DockerSocket() + if err != nil { + t.Fatalf("DockerSocket: %v", err) + } + if got != sockPath { + t.Fatalf("got %q, want %q", got, sockPath) + } + if _, err := os.Stat(sockPath); err != nil { + t.Fatalf("socket should exist: %v", err) + } +} diff --git a/internal/devenv/e2e_gate_test.go b/internal/devenv/e2e_gate_test.go new file mode 100644 index 000000000..74f609931 --- /dev/null +++ b/internal/devenv/e2e_gate_test.go @@ -0,0 +1,17 @@ +//go:build devenv_e2e + +package devenv + +import ( + "os" + "testing" + + "github.com/Automattic/vip/internal/devenv/e2esafety" +) + +func TestMain(m *testing.M) { + if e2esafety.Skip(os.Getenv, os.Stdout) { + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/internal/devenv/e2e_test.go b/internal/devenv/e2e_test.go new file mode 100644 index 000000000..82a8309a2 --- /dev/null +++ b/internal/devenv/e2e_test.go @@ -0,0 +1,499 @@ +//go:build devenv_e2e + +// Package devenv e2e harness — the Plan 4 lifecycle integration gate (Task 15). +// +// Build-tagged (devenv_e2e) so it never runs in CI or a normal `go test`. It +// drives the REAL devenv public API against a live Docker daemon and performs +// the host-privileged trust + /etc/hosts step (one sudo prompt). Run on a macOS +// machine with Docker, from a terminal (sudo needs a TTY): +// +// go test -tags devenv_e2e -run TestDevEnvLifecycleE2E -v \ +// -timeout 10m ./internal/devenv/ +// +// It exercises create (no start) -> Start (one sudo prompt: trust CA + +// /etc/hosts) -> Stop -> re-Start (idempotency) -> Destroy (cleanup). A +// migration sub-scenario is added once the greenfield path passes (pre-seed a +// "<prefix>_database_data" volume and confirm the external mapping + data +// survival). +package devenv + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/e2esafety" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/paths" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +const ( + lifecycleProxyContainer = "proxy-container" + lifecycleProxyNetwork = "proxy-network" + lifecycleCertsVolume = "certs-volume" + lifecycleConfigVolume = "config-volume" + lifecycleTrustedCA = "trusted-ca" + lifecycleManagedHosts = "managed-hosts" + lifecycleCAHostFile = "ca-host-file" + lifecyclePortsState = "ports-state-file" + + lifecycleBeginMarker = "# BEGIN vip-dev-env" + lifecycleEndMarker = "# END vip-dev-env" +) + +func TestDevEnvLifecycleE2E(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skipf("e2e is macOS-only; GOOS=%s", runtime.GOOS) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + ctx := context.Background() + slug := uniqueLifecycleE2ESlug("e2eexample") + + before := captureLifecycleSharedSnapshot(t, ctx) + if err := before.RequireClean(); err != nil { + t.Fatal(err) + } + owned := e2esafety.Snapshot{} + t.Cleanup(func() { cleanupLifecycleE2E(t, ctx, slug, owned) }) + + // create (no start) then start + if err := Create(ctx, CreateConfig{Slug: slug, Title: "E2E", PHP: "8.3", WordPress: "trunk"}); err != nil { + t.Fatalf("Create: %v", err) + } + if !instancedata.Exists(slug) { + t.Fatal("instance-data not written") + } + t.Log(">>> sudo will prompt once (trust CA + /etc/hosts) <<<") + if err := Start(ctx, slug, StartOptions{}); err != nil { + t.Fatalf("Start: %v", err) + } + recordLifecycleOwned(t, owned, captureLifecycleSharedSnapshot(t, ctx)) + + // Stop then re-Start (idempotency) + if err := Stop(ctx, slug); err != nil { + t.Fatalf("Stop: %v", err) + } + if err := Start(ctx, slug, StartOptions{}); err != nil { + t.Fatalf("re-Start: %v", err) + } +} + +// TestMultisiteSyncLocalE2E exercises the complete local sync path with fixture +// SQL and fixture SDS data. It never constructs a platform API client or sends +// export/sync payloads outside this process. +func TestMultisiteSyncLocalE2E(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skipf("e2e is macOS-only; GOOS=%s", runtime.GOOS) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + binaryName := "go-search-replace-test-darwin-arm64" + if runtime.GOARCH == "amd64" { + binaryName = "go-search-replace-test-darwin-x64" + } + searchReplaceBin, err := filepath.Abs(filepath.Join("..", "..", "__fixtures__", "search-replace-binaries", binaryName)) + if err != nil { + t.Fatal(err) + } + t.Setenv("VIP_SEARCH_REPLACE_BIN", searchReplaceBin) + + ctx := context.Background() + slug := uniqueLifecycleE2ESlug("e2emultisite") + before := captureLifecycleSharedSnapshot(t, ctx) + if err := before.RequireClean(); err != nil { + t.Fatal(err) + } + owned := e2esafety.Snapshot{} + t.Cleanup(func() { cleanupLifecycleE2E(t, ctx, slug, owned) }) + + if err := Create(ctx, CreateConfig{ + Slug: slug, Title: "E2E Multisite", PHP: "8.3", WordPress: "trunk", MultisiteMode: "subdomain", + }); err != nil { + t.Fatalf("Create: %v", err) + } + t.Log(">>> sudo may prompt for local CA and managed hosts setup <<<") + if err := Start(ctx, slug, StartOptions{}); err != nil { + t.Fatalf("Start: %v", err) + } + recordLifecycleOwned(t, owned, captureLifecycleSharedSnapshot(t, ctx)) + + fixtureSQL := ` +-- ('home','https://primary.example.com', +-- ('home','https://sub.primary.example.com', +-- ('home','https://deep.sub.primary.example.com', +-- ('home','https://mapped.example.net', +UPDATE wordpress.wp_options SET option_value = 'https://primary.example.com' WHERE option_name IN ('home', 'siteurl'); +DELETE FROM wordpress.wp_blogs WHERE blog_id IN (2, 7, 9); +INSERT INTO wordpress.wp_blogs + (blog_id, site_id, domain, path, registered, last_updated, public, archived, mature, spam, deleted, lang_id) +VALUES + (2, 1, 'sub.primary.example.com', '/', '2026-07-14 00:00:00', '2026-07-14 00:00:00', 1, 0, 0, 0, 0, 0), + (7, 1, 'deep.sub.primary.example.com', '/', '2026-07-14 00:00:00', '2026-07-14 00:00:00', 1, 0, 0, 0, 0, 0), + (9, 1, 'mapped.example.net', '/', '2026-07-14 00:00:00', '2026-07-14 00:00:00', 1, 0, 0, 0, 0, 0); +` + sites := []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com", SiteURL: "https://primary.example.com"}, + {BlogID: 2, HomeURL: "https://sub.primary.example.com", SiteURL: "https://sub.primary.example.com"}, + {BlogID: 7, HomeURL: "https://deep.sub.primary.example.com", SiteURL: "https://deep.sub.primary.example.com"}, + {BlogID: 9, HomeURL: "https://mapped.example.net", SiteURL: "https://mapped.example.net"}, + } + if err := syncSQLWith(ctx, SyncOptions{ + Slug: slug, Domain: compose.DefaultDomain, IsMultisite: true, + }, SyncDeps{ + ExportTo: func(_ context.Context, dest string) error { + return os.WriteFile(dest, []byte(fixtureSQL), 0o600) + }, + FetchSites: func(context.Context) ([]SyncSite, string) { return sites, "" }, + ResolveDraft: func(draft PlanDraft) ([]string, error) { + return nil, fmt.Errorf("unexpected unresolved mappings: %#v", draft.Unresolved) + }, + ImportFile: func(ctx context.Context, slug, file string, pairs []string) error { + return ImportSQL(ctx, slug, file, ImportOptions{ + SearchReplace: pairs, InPlace: true, SkipValidate: true, Quiet: true, + }) + }, + RepairDomains: RepairBlogDomains, + RefreshHosts: RefreshManagedHosts, + Log: func(line string) { t.Log(line) }, + }); err != nil { + t.Fatalf("syncSQLWith: %v", err) + } + recordLifecycleOwned(t, owned, captureLifecycleSharedSnapshot(t, ctx)) + + runner := &dockercli.Runner{} + home, err := runner.ComposeOut(ctx, slug, + "exec", "-T", "php", "wp", "--allow-root", "option", "get", "home") + if err != nil { + t.Fatalf("read home: %v", err) + } + baseHost := slug + "." + compose.DefaultDomain + if got := strings.TrimSpace(string(home)); got != "https://"+baseHost { + t.Fatalf("home = %q, want https://%s", got, baseHost) + } + domains, err := runner.ComposeOut(ctx, slug, + "exec", "-T", "php", "wp", "--allow-root", "db", "query", + "SELECT blog_id, domain FROM wordpress.wp_blogs ORDER BY blog_id", "--skip-column-names") + if err != nil { + t.Fatalf("read wp_blogs: %v", err) + } + wantDomains := []string{ + "sub." + baseHost, + "deep-sub-b7." + baseHost, + "mapped-example-net-b9." + baseHost, + } + for _, domain := range wantDomains { + if !strings.Contains(string(domains), domain) { + t.Errorf("wp_blogs output missing %q:\n%s", domain, domains) + } + } + if !hostops.HostsPresent(append([]string{baseHost}, wantDomains...)) { + t.Fatalf("managed hosts block does not contain base and subsite targets") + } +} + +func TestDevEnvLandoAdoptionE2E(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skipf("e2e is macOS-only; GOOS=%s", runtime.GOOS) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + ctx := context.Background() + slug := uniqueLifecycleE2ESlug("e2elando") + fake := slug + "-fake-lando" + + before := captureLifecycleSharedSnapshot(t, ctx) + if err := before.RequireClean(); err != nil { + t.Fatal(err) + } + r := &dockercli.Runner{} + if identity := lifecycleDockerObjectIdentity(t, ctx, r, "container", fake, "{{.Id}}"); identity != "" { + t.Fatalf("devenv_e2e refuses to replace existing test container %s", fake) + } + owned := e2esafety.Snapshot{} + t.Cleanup(func() { cleanupLifecycleE2E(t, ctx, slug, owned) }) + + // 1. Greenfield create+start to get a real database_data volume with data. + if err := Create(ctx, CreateConfig{Slug: slug, Title: "E2E Lando", PHP: "8.3", WordPress: "trunk"}); err != nil { + t.Fatalf("Create: %v", err) + } + t.Log(">>> sudo will prompt once (trust CA + /etc/hosts) <<<") + if err := Start(ctx, slug, StartOptions{}); err != nil { + t.Fatalf("Start: %v", err) + } + recordLifecycleOwned(t, owned, captureLifecycleSharedSnapshot(t, ctx)) + + // Seed a marker row we can check survives adoption. + const marker = "vip-adoption-marker" + seed := exec.Command("docker", "compose", "-p", slug, "exec", "-T", "database", + "mysql", "-uwordpress", "-pwordpress", "wordpress", "-e", + "CREATE TABLE IF NOT EXISTS adopt_check (v VARCHAR(64)); INSERT INTO adopt_check VALUES ('"+marker+"');") + seed.Dir = paths.EnvironmentPath(slug) + if out, err := seed.CombinedOutput(); err != nil { + t.Fatalf("seed marker: %v: %s", err, out) + } + + // 2. Simulate a leftover Lando footprint: stop the Go containers, then create a + // container carrying Lando's labels + this project label bound to the SAME volume. + if err := Stop(ctx, slug); err != nil { + t.Fatalf("Stop: %v", err) + } + run := exec.Command("docker", "run", "-d", "--name", fake, + "--label", "com.docker.compose.project="+slug, + "--label", "io.lando.container=TRUE", + "-v", slug+"_database_data:/var/lib/mysql", + "busybox", "sleep", "3600") + if out, err := run.CombinedOutput(); err != nil { + t.Fatalf("seed fake Lando container: %v: %s", err, out) + } + + // 3. Detect + adopt via Start. + plan, err := PlanLandoMigration(ctx, slug) + if err != nil { + t.Fatalf("PlanLandoMigration: %v", err) + } + if !plan.Detected { + t.Fatal("expected a Lando footprint to be detected") + } + if err := Start(ctx, slug, StartOptions{Lando: &plan}); err != nil { + t.Fatalf("adopting Start: %v", err) + } + + // 4a. The fake Lando container is gone. + if err := exec.Command("docker", "inspect", fake).Run(); err == nil { + t.Fatal("fake Lando container should have been removed by adoption") + } + // 4b. The marker row survived (same volume reused, never -v'd). + read := exec.Command("docker", "compose", "-p", slug, "exec", "-T", "database", + "mysql", "-uwordpress", "-pwordpress", "wordpress", "-N", "-e", "SELECT v FROM adopt_check;") + read.Dir = paths.EnvironmentPath(slug) + out, err := read.CombinedOutput() + if err != nil { + t.Fatalf("read marker: %v: %s", err, out) + } + if !strings.Contains(string(out), marker) { + t.Fatalf("marker row did not survive adoption; got %q", out) + } + // 4c. The instance_data marker is stamped. + d, err := instancedata.Read(slug) + if err != nil { + t.Fatalf("Read: %v", err) + } + if d.MigratedFromLando == "" { + t.Fatal("expected migratedFromLando marker to be stamped") + } +} + +func uniqueLifecycleE2ESlug(prefix string) string { + return fmt.Sprintf("%s-%d-%d", prefix, os.Getpid(), time.Now().UnixNano()) +} + +func captureLifecycleSharedSnapshot(t *testing.T, ctx context.Context) e2esafety.Snapshot { + t.Helper() + r := &dockercli.Runner{} + return e2esafety.Snapshot{ + lifecycleProxyContainer: lifecycleDockerObjectIdentity(t, ctx, r, "container", proxy.ProxyContainerName, "{{.Id}}"), + lifecycleProxyNetwork: lifecycleDockerObjectIdentity(t, ctx, r, "network", compose.ProxyNetwork, "{{.Id}}"), + lifecycleCertsVolume: lifecycleDockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyCertsVolume, "{{.Name}}|{{.CreatedAt}}"), + lifecycleConfigVolume: lifecycleDockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyConfigVolume, "{{.Name}}|{{.CreatedAt}}"), + lifecycleTrustedCA: lifecycleTrustedCAIdentity(t), + lifecycleManagedHosts: lifecycleManagedHostsIdentity(t, "/etc/hosts"), + lifecycleCAHostFile: lifecycleFileIdentity(t, proxy.CAHostPath()), + lifecyclePortsState: lifecycleFileIdentity(t, proxy.PortsStatePath()), + } +} + +func lifecycleDockerObjectIdentity(t *testing.T, ctx context.Context, r *dockercli.Runner, kind, name, format string) string { + t.Helper() + out, err := r.DockerOut(ctx, kind, "inspect", "--format", format, name) + if err == nil { + identity := strings.TrimSpace(string(out)) + if identity == "" { + t.Fatalf("docker %s inspect returned an empty identity for %q", kind, name) + } + return identity + } + + listFormat := "{{.Name}}" + var listed []byte + var listErr error + if kind == "container" { + listFormat = "{{.Names}}" + listed, listErr = r.DockerOut(ctx, kind, "ls", "--all", "--filter", "name="+name, "--format", listFormat) + } else { + listed, listErr = r.DockerOut(ctx, kind, "ls", "--filter", "name="+name, "--format", listFormat) + } + if listErr != nil { + t.Fatalf("docker %s lookup for %q failed after inspect error: %v", kind, name, listErr) + } + for _, candidate := range strings.Split(strings.TrimSpace(string(listed)), "\n") { + if candidate == name { + t.Fatalf("docker %s %q exists but its identity could not be inspected: %v", kind, name, err) + } + } + return "" +} + +func lifecycleTrustedCAIdentity(t *testing.T) string { + t.Helper() + out, err := exec.Command("security", "find-certificate", "-a", "-c", "WPVIP Local CA", "-p", + "/Library/Keychains/System.keychain").CombinedOutput() + if err != nil { + if strings.Contains(string(out), "could not be found") { + return "" + } + t.Fatalf("read trusted WPVIP Local CA: %v: %s", err, strings.TrimSpace(string(out))) + } + block, rest := pem.Decode(out) + if block == nil { + t.Fatal("trusted WPVIP Local CA is not valid PEM") + } + if len(strings.TrimSpace(string(rest))) != 0 { + t.Fatal("multiple WPVIP Local CA certificates found; refusing ambiguous ownership") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse trusted WPVIP Local CA: %v", err) + } + return lifecycleHashIdentity(cert.Raw) +} + +func lifecycleManagedHostsIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read hosts file %s: %v", path, err) + } + content := string(b) + if strings.Count(content, lifecycleBeginMarker) != strings.Count(content, lifecycleEndMarker) { + t.Fatalf("malformed managed hosts block in %s", path) + } + if strings.Count(content, lifecycleBeginMarker) > 1 { + t.Fatalf("multiple managed hosts blocks found in %s; refusing ambiguous ownership", path) + } + start := strings.Index(content, lifecycleBeginMarker) + end := strings.Index(content, lifecycleEndMarker) + if start < 0 && end < 0 { + return "" + } + if start < 0 || end < start { + t.Fatalf("malformed managed hosts block in %s", path) + } + end += len(lifecycleEndMarker) + if end < len(content) && content[end] == '\n' { + end++ + } + return lifecycleHashIdentity([]byte(content[start:end])) +} + +func lifecycleFileIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return lifecycleHashIdentity(b) +} + +func lifecycleHashIdentity(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func recordLifecycleOwned(t *testing.T, owned, current e2esafety.Snapshot) { + t.Helper() + for name, identity := range current { + if identity == "" { + t.Fatalf("created shared resource %s has no identity", name) + } + owned[name] = identity + } +} + +func cleanupLifecycleE2E(t *testing.T, ctx context.Context, slug string, owned e2esafety.Snapshot) { + t.Helper() + current := captureLifecycleSharedSnapshot(t, ctx) + if len(owned) == 0 || !e2esafety.AllOwnedMatch(owned, current) { + t.Log("shared dev-env identity changed; refusing broad Destroy and leaving shared state untouched") + cmd := exec.Command("docker", "compose", "-p", slug, "down", "--volumes", "--remove-orphans") + cmd.Dir = paths.EnvironmentPath(slug) + if err := cmd.Run(); err != nil { + t.Logf("isolated project cleanup failed: %v", err) + } + return + } + if err := Destroy(ctx, slug, false); err != nil { + t.Logf("owned lifecycle teardown failed: %v", err) + return + } + + // Destroy intentionally preserves the CA and some shared state in normal use. + // A tagged test owns a clean-room setup, so remove only identities it created. + current = captureLifecycleSharedSnapshot(t, ctx) + r := &dockercli.Runner{} + removeDocker := func(key string, args ...string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if current[key] != "" { + t.Logf("%s identity changed; leaving it for manual cleanup", key) + } + return + } + if err := r.Docker(ctx, args...); err != nil { + t.Logf("remove owned %s: %v", key, err) + } + } + removeDocker(lifecycleProxyContainer, "rm", "-f", proxy.ProxyContainerName) + removeDocker(lifecycleConfigVolume, "volume", "rm", proxy.ProxyConfigVolume) + removeDocker(lifecycleCertsVolume, "volume", "rm", proxy.ProxyCertsVolume) + removeDocker(lifecycleProxyNetwork, "network", "rm", compose.ProxyNetwork) + + if e2esafety.CanRemove(owned[lifecycleTrustedCA], current[lifecycleTrustedCA]) && + e2esafety.CanRemove(owned[lifecycleCAHostFile], current[lifecycleCAHostFile]) { + cmd := exec.Command("sudo", "security", "remove-trusted-cert", "-d", proxy.CAHostPath()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Logf("remove owned trusted CA: %v", err) + } + } else if current[lifecycleTrustedCA] != "" { + t.Log("trusted CA or extracted CA identity changed; leaving it for manual cleanup") + } + + removeFile := func(key, path string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if current[key] != "" { + t.Logf("%s identity changed; leaving it for manual cleanup", key) + } + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Logf("remove owned %s: %v", key, err) + } + } + removeFile(lifecycleCAHostFile, proxy.CAHostPath()) + removeFile(lifecyclePortsState, proxy.PortsStatePath()) +} diff --git a/internal/devenv/e2esafety/gate_wiring_test.go b/internal/devenv/e2esafety/gate_wiring_test.go new file mode 100644 index 000000000..051a050ad --- /dev/null +++ b/internal/devenv/e2esafety/gate_wiring_test.go @@ -0,0 +1,90 @@ +package e2esafety + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDestructivePackagesHavePackageLevelGate(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + for _, rel := range []string{ + "internal/devenv/e2e_gate_test.go", + "internal/devenv/hostops/e2e_gate_test.go", + "cmd/vip-next/commands/devenv_e2e_gate_test.go", + } { + b, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + t.Errorf("%s: %v", rel, err) + continue + } + source := string(b) + if !strings.Contains(source, "func TestMain(m *testing.M)") { + t.Errorf("%s has no package-level TestMain", rel) + } + if !strings.Contains(source, "e2esafety.Skip(os.Getenv, os.Stdout)") { + t.Errorf("%s does not invoke the runtime opt-in gate", rel) + } + } +} + +func TestDocumentationOnlyCommandGatesCannotReportPass(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + path := filepath.Join(repoRoot, "cmd/vip-next/commands/devenv_e2e_test.go") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + source := string(b) + if strings.Contains(source, "t.Log(") { + t.Fatal("documentation-only devenv_e2e functions must skip, not log and pass") + } + if got := strings.Count(source, "t.Skip("); got != 4 { + t.Fatalf("documentation-only skip count = %d, want 4", got) + } +} + +func TestTaggedSuitesRequireCleanStateAndIdentityCheckedCleanup(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + + hostopsSource, err := os.ReadFile(filepath.Join(repoRoot, "internal/devenv/hostops/e2e_test.go")) + if err != nil { + t.Fatal(err) + } + hostops := string(hostopsSource) + for _, required := range []string{"before.RequireClean()", "e2esafety.CanRemove", "teardownOwned"} { + if !strings.Contains(hostops, required) { + t.Errorf("hostops e2e is missing %q", required) + } + } + for _, forbidden := range []string{"func preclean(", "proxy.Cleanup(ctx, r)"} { + if strings.Contains(hostops, forbidden) { + t.Errorf("hostops e2e still contains unsafe cleanup %q", forbidden) + } + } + + lifecycleSource, err := os.ReadFile(filepath.Join(repoRoot, "internal/devenv/e2e_test.go")) + if err != nil { + t.Fatal(err) + } + lifecycle := string(lifecycleSource) + for _, required := range []string{"before.RequireClean()", "e2esafety.AllOwnedMatch", "cleanupLifecycleE2E"} { + if !strings.Contains(lifecycle, required) { + t.Errorf("lifecycle e2e is missing %q", required) + } + } +} diff --git a/internal/devenv/e2esafety/safety.go b/internal/devenv/e2esafety/safety.go new file mode 100644 index 000000000..b39bff4f4 --- /dev/null +++ b/internal/devenv/e2esafety/safety.go @@ -0,0 +1,52 @@ +// Package e2esafety contains side-effect-free policy for destructive tagged tests. +package e2esafety + +import ( + "fmt" + "io" + "sort" + "strings" +) + +const GateMessage = "SKIP devenv_e2e: set VIP_DEVENV_E2E=1 to permit Docker, certificate, and hosts changes" + +func Enabled(getenv func(string) string) bool { + return getenv("VIP_DEVENV_E2E") == "1" +} + +func Skip(getenv func(string) string, out io.Writer) bool { + if Enabled(getenv) { + return false + } + fmt.Fprintln(out, GateMessage) + return true +} + +type Snapshot map[string]string + +func (s Snapshot) RequireClean() error { + var existing []string + for name, identity := range s { + if identity != "" { + existing = append(existing, name) + } + } + if len(existing) == 0 { + return nil + } + sort.Strings(existing) + return fmt.Errorf("devenv_e2e refuses to modify existing shared state: %s; clean or isolate it manually", strings.Join(existing, ", ")) +} + +func CanRemove(created, current string) bool { + return created != "" && current == created +} + +func AllOwnedMatch(owned, current Snapshot) bool { + for name, created := range owned { + if created != "" && !CanRemove(created, current[name]) { + return false + } + } + return true +} diff --git a/internal/devenv/e2esafety/safety_test.go b/internal/devenv/e2esafety/safety_test.go new file mode 100644 index 000000000..f886e0651 --- /dev/null +++ b/internal/devenv/e2esafety/safety_test.go @@ -0,0 +1,71 @@ +package e2esafety + +import ( + "bytes" + "strings" + "testing" +) + +func TestEnabledRequiresExactOptIn(t *testing.T) { + for _, tc := range []struct { + value string + want bool + }{{"", false}, {"0", false}, {"true", false}, {"1", true}} { + got := Enabled(func(string) string { return tc.value }) + if got != tc.want { + t.Errorf("Enabled(%q) = %v, want %v", tc.value, got, tc.want) + } + } +} + +func TestSkipPrintsExplicitMessage(t *testing.T) { + var out bytes.Buffer + if !Skip(func(string) string { return "" }, &out) { + t.Fatal("Skip must stop the package when opt-in is absent") + } + if !strings.Contains(out.String(), "VIP_DEVENV_E2E=1") { + t.Fatalf("skip message = %q", out.String()) + } +} + +func TestRequireCleanListsExistingResources(t *testing.T) { + s := Snapshot{ + "proxy-container": "container-id", + "managed-hosts": "hosts-sha256", + } + err := s.RequireClean() + if err == nil || !strings.Contains(err.Error(), "managed-hosts, proxy-container") { + t.Fatalf("RequireClean error = %v", err) + } +} + +func TestRequireCleanAllowsEmptySnapshot(t *testing.T) { + if err := (Snapshot{}).RequireClean(); err != nil { + t.Fatalf("empty snapshot must be clean: %v", err) + } +} + +func TestCanRemoveRequiresExactCreatedIdentity(t *testing.T) { + for _, tc := range []struct { + created string + current string + want bool + }{{"", "x", false}, {"x", "", false}, {"x", "replacement", false}, {"x", "x", true}} { + if got := CanRemove(tc.created, tc.current); got != tc.want { + t.Errorf("CanRemove(%q, %q) = %v, want %v", tc.created, tc.current, got, tc.want) + } + } +} + +func TestAllOwnedMatchRejectsReplacementAndMissingResources(t *testing.T) { + owned := Snapshot{"container": "container-1", "network": "network-1"} + if !AllOwnedMatch(owned, Snapshot{"container": "container-1", "network": "network-1"}) { + t.Fatal("exact identities must match") + } + if AllOwnedMatch(owned, Snapshot{"container": "container-2", "network": "network-1"}) { + t.Fatal("replacement container must not match") + } + if AllOwnedMatch(owned, Snapshot{"container": "container-1"}) { + t.Fatal("missing owned network must not match") + } +} diff --git a/internal/devenv/eachenv_test.go b/internal/devenv/eachenv_test.go new file mode 100644 index 000000000..948f02635 --- /dev/null +++ b/internal/devenv/eachenv_test.go @@ -0,0 +1,132 @@ +package devenv + +import ( + "errors" + "strings" + "testing" +) + +// Node's `dev-env stop --all` wraps each stopEnvironment in its own try/catch: +// a failure prints an error, sets process.exitCode = 1, and the loop CONTINUES +// to the next environment (vip-dev-env-stop.js:72-100). vip-next returned on +// the first error, leaving the remaining environments running — so one broken +// environment stopped `stop --all` from stopping anything after it. +func TestStopAllContinuesPastAFailure(t *testing.T) { + var seen []string + err := stopEachEnv([]string{"a", "bad", "c"}, func(slug string) error { + seen = append(seen, slug) + if slug == "bad" { + return errors.New("container is wedged") + } + return nil + }) + if err == nil { + t.Fatal("a failed environment must still fail the command (Node sets exitCode 1)") + } + if got := strings.Join(seen, ","); got != "a,bad,c" { + t.Errorf("visited %q; every environment must be attempted", got) + } + if !strings.Contains(err.Error(), "bad") || !strings.Contains(err.Error(), "container is wedged") { + t.Errorf("error %q should name the failing environment and its cause", err) + } +} + +func TestStopAllSucceedsWhenAllSucceed(t *testing.T) { + if err := stopEachEnv([]string{"a", "b"}, func(string) error { return nil }); err != nil { + t.Fatalf("err = %v, want nil", err) + } +} + +// Node's purge has the same shape (vip-dev-env-purge.js:85-98): each +// destroyEnvironment is individually caught, exitCode is set to 1, and the loop +// continues. vip-next aborted on the first failure, leaving a half-purged +// machine that the user then had to clean up by hand. +func TestPurgeContinuesPastAFailure(t *testing.T) { + var seen []string + err := purgeEachEnv([]string{"a", "bad", "c"}, func(slug string) error { + seen = append(seen, slug) + if slug == "bad" { + return errors.New("volume in use") + } + return nil + }) + if err == nil { + t.Fatal("a failed environment must still fail purge") + } + if got := strings.Join(seen, ","); got != "a,bad,c" { + t.Errorf("visited %q; purge must attempt every environment", got) + } +} + +// Node deletes the environment directory INSIDE destroyEnvironment, with a bare +// `fs.promises.rm( instancePath, { recursive: true } )` — note: no +// `force: true` (src/lib/dev-environment/dev-environment-core.ts:381-382). A +// failed removal therefore rejects, and the purge bin catches it per environment +// and sets `process.exitCode = 1` (src/bin/vip-dev-env-purge.js:92-97). +// +// vip-next's Purge did `_ = removeEnvFiles(slug)`. When removal failed (a +// read-only parent, a busy or immutable file) the environment's config survived +// — so it was still listed by `dev-env list` and still counted by +// instancedata.AllNames() — while purge reported success and exited 0. Note the +// single-environment Destroy path already propagated the same error, so this +// was an inconsistency inside vip-next as well as against Node. +func TestPurgeStepFailsWhenEnvFilesCannotBeRemoved(t *testing.T) { + removeCalls := 0 + step := purgeEnvStep( + func(string) error { return nil }, + func(string) error { removeCalls++; return errors.New("permission denied") }, + false, // not a soft purge → files must be removed + ) + err := step("wedged") + if err == nil { + t.Fatal("a failed environment-files removal must fail the purge (Node exits 1)") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Errorf("error %q should carry the removal failure", err) + } + if removeCalls != 1 { + t.Errorf("removeEnvFiles called %d times, want 1", removeCalls) + } +} + +// A soft purge keeps the config files (Node's `--soft`), so removal must not be +// attempted at all — and its hypothetical failure must not surface. +func TestPurgeStepSoftSkipsFileRemoval(t *testing.T) { + step := purgeEnvStep( + func(string) error { return nil }, + func(string) error { t.Error("soft purge must not remove environment files"); return errors.New("boom") }, + true, + ) + if err := step("kept"); err != nil { + t.Fatalf("err = %v, want nil", err) + } +} + +// A destroy failure short-circuits: removal is not attempted on an environment +// whose containers are still up. +func TestPurgeStepDestroyFailureSkipsRemoval(t *testing.T) { + step := purgeEnvStep( + func(string) error { return errors.New("volume in use") }, + func(string) error { t.Error("must not remove files after a failed destroy"); return nil }, + false, + ) + err := step("busy") + if err == nil || !strings.Contains(err.Error(), "volume in use") { + t.Fatalf("err = %v, want the destroy failure", err) + } +} + +// Every failure is reported, not just the first. +func TestPurgeReportsEveryFailure(t *testing.T) { + err := purgeEachEnv([]string{"x", "y"}, func(slug string) error { + return errors.New("nope-" + slug) + }) + if err == nil { + t.Fatal("want an error") + } + for _, want := range []string{"nope-x", "nope-y"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err, want) + } + } +} diff --git a/internal/devenv/editor.go b/internal/devenv/editor.go new file mode 100644 index 000000000..f003cdbe4 --- /dev/null +++ b/internal/devenv/editor.go @@ -0,0 +1,82 @@ +package devenv + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// vscodeStyleEditors share the .code-workspace format (Node SUPPORTED_EDITORS). +var vscodeStyleEditors = map[string]bool{"vscode": true, "cursor": true, "windsurf": true} + +// GenerateEditorWorkspace writes an editor workspace file for the env and +// returns its path (Node `start --editor=<name>`). Ports generateVSCodeWorkspace: +// a .code-workspace with the env + local code folders and an Xdebug launch +// config (port 9003) with container→host path mappings. vscode/cursor/windsurf +// share the format; phpstorm (a different .iml format) is not yet supported. +func GenerateEditorWorkspace(slug, editor string) (string, error) { + if !vscodeStyleEditors[editor] { + return "", fmt.Errorf("devenv: --editor=%s is not supported yet; use vscode, cursor, or windsurf", editor) + } + d, err := instancedata.Read(slug) + if err != nil { + return "", err + } + location := paths.EnvironmentPath(slug) + + folders := []map[string]string{{"path": location}} + if d.MuPlugins.Dir != "" { + folders = append(folders, map[string]string{"path": d.MuPlugins.Dir}) + } + if d.AppCode.Dir != "" { + folders = append(folders, map[string]string{"path": d.AppCode.Dir}) + } + + workspace := map[string]any{ + "folders": folders, + "launch": map[string]any{ + "version": "0.2.0", + "configurations": []map[string]any{{ + "name": "Debug " + slug, + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": editorPathMappings(location, d), + }}, + }, + } + b, err := json.MarshalIndent(workspace, "", " ") + if err != nil { + return "", err + } + out := filepath.Join(location, slug+".code-workspace") + if err := os.WriteFile(out, b, 0o644); err != nil { + return "", err + } + return out, nil +} + +// editorPathMappings maps container paths to host paths for the Xdebug launch +// config (ports generatePathMappings). +func editorPathMappings(location string, d *instancedata.InstanceData) map[string]string { + m := map[string]string{} + if d.MuPlugins.Dir != "" { + m["/wp/wp-content/mu-plugins"] = d.MuPlugins.Dir + } + if d.AppCode.Dir != "" { + base := d.AppCode.Dir + m["/wp/wp-content/client-mu-plugins"] = filepath.Join(base, "client-mu-plugins") + m["/wp/wp-content/images"] = filepath.Join(base, "images") + m["/wp/wp-content/languages"] = filepath.Join(base, "languages") + m["/wp/wp-content/plugins"] = filepath.Join(base, "plugins") + m["/wp/wp-content/private"] = filepath.Join(base, "private") + m["/wp/wp-content/themes"] = filepath.Join(base, "themes") + m["/wp/vip-config"] = filepath.Join(base, "vip-config") + } + m["/wp"] = filepath.Join(location, "wordpress") + return m +} diff --git a/internal/devenv/editor_test.go b/internal/devenv/editor_test.go new file mode 100644 index 000000000..0488dadbe --- /dev/null +++ b/internal/devenv/editor_test.go @@ -0,0 +1,60 @@ +package devenv + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestEditorPathMappingsAppCode(t *testing.T) { + d := &instancedata.InstanceData{ + MuPlugins: instancedata.ComponentConfig{Mode: "local", Dir: "/srv/mu"}, + AppCode: instancedata.ComponentConfig{Mode: "local", Dir: "/srv/app"}, + } + m := editorPathMappings("/loc", d) + if m["/wp/wp-content/mu-plugins"] != "/srv/mu" { + t.Fatalf("mu-plugins mapping wrong: %v", m) + } + if m["/wp/wp-content/plugins"] != "/srv/app/plugins" { + t.Fatalf("plugins mapping wrong: %v", m) + } + if m["/wp"] != "/loc/wordpress" { + t.Fatalf("/wp mapping wrong: %v", m) + } +} + +func TestGenerateEditorWorkspace(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("ws", &instancedata.InstanceData{SiteSlug: "ws", Multisite: []byte("false")}); err != nil { + t.Fatal(err) + } + path, err := GenerateEditorWorkspace("ws", "cursor") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(path, "ws.code-workspace") { + t.Fatalf("unexpected workspace path: %s", path) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var ws map[string]any + if err := json.Unmarshal(b, &ws); err != nil { + t.Fatalf("workspace not valid JSON: %v", err) + } + if _, ok := ws["folders"]; !ok { + t.Fatal("workspace missing folders") + } + if filepath.Base(path) != "ws.code-workspace" { + t.Fatal("workspace filename wrong") + } + // Unsupported editor errors. + if _, err := GenerateEditorWorkspace("ws", "phpstorm"); err == nil { + t.Fatal("phpstorm should be unsupported") + } +} diff --git a/internal/devenv/envfile.go b/internal/devenv/envfile.go new file mode 100644 index 000000000..7d5f8f697 --- /dev/null +++ b/internal/devenv/envfile.go @@ -0,0 +1,287 @@ +package devenv + +import ( + "os" + "path/filepath" + "strings" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// This file ports src/lib/dev-environment/env-vars.ts. +// +// <envdir>/.env is SHARED with the Node CLI: paths.EnvironmentPath is +// byte-identical to Node's getEnvironmentPath, and Node's `vip dev-env envvar +// set|delete|get|list` read and write exactly this file (env-vars.ts:66). It is +// also the delivery mechanism on both sides — Node's Lando template declares +// `env_file: - .env` and Go's php service does the same +// (compose/services.go) — so a variable written here reaches the container +// without any further plumbing. +// +// vip-next additionally needs LANDO_HOST_USER_ID/LANDO_HOST_GROUP_ID in this +// file because docker-compose.yml substitutes ${LANDO_HOST_USER_ID} at parse +// time and Compose resolves those from the project directory's .env. Node does +// not write them here (it injects them through Lando's own config, +// dev-environment-lando.ts:336) and only ever touches the file with +// appendFileSync(path, '') on start. Those two keys are therefore treated as +// "managed": vip-next rewrites them and leaves every other byte alone. + +// splitEnvLine ports Node's splitKeyValueString (src/lib/utils.ts:108): split on +// the FIRST '=', trim both sides. A line with no '=' is a key with an empty +// value. +func splitEnvLine(line string) (key, value string) { + k, v, ok := strings.Cut(line, "=") + if !ok { + return strings.TrimSpace(line), "" + } + return strings.TrimSpace(k), strings.TrimSpace(v) +} + +// parseManagedBlock turns the rendered managed block (compose.RenderEnvFile) +// into ordered key/value pairs, so the set of Go-managed keys has exactly one +// definition instead of being duplicated as a literal list here. +func parseManagedBlock(block string) [][2]string { + var out [][2]string + for _, line := range strings.Split(block, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v := splitEnvLine(line) + if k != "" { + out = append(out, [2]string{k, v}) + } + } + return out +} + +// mergeEnvFile applies the managed key/values onto an existing .env, preserving +// every other line verbatim — user variables, comments, blank lines and +// ordering all survive. Managed keys already present are rewritten where they +// stand; missing ones are appended. On an empty input the result is just the +// managed block, i.e. identical to what Materialize used to write outright. +// +// This replaces an unconditional overwrite that destroyed variables set with +// the Node CLI on every create/start/rebuild/update (parity blocker B3). +func mergeEnvFile(existing, managedBlock string) string { + managed := parseManagedBlock(managedBlock) + if existing == "" { + return managedBlock + } + + // Preserve the original line endings by splitting on "\n" and keeping any + // trailing "\r" attached to the content we don't rewrite. + lines := strings.Split(existing, "\n") + trailingNewline := len(lines) > 0 && lines[len(lines)-1] == "" + if trailingNewline { + lines = lines[:len(lines)-1] + } + + seen := make(map[string]bool, len(managed)) + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + key, _ := splitEnvLine(trimmed) + for _, kv := range managed { + if key == kv[0] && !seen[key] { + lines[i] = kv[0] + "=" + kv[1] + seen[key] = true + } + } + } + for _, kv := range managed { + if !seen[kv[0]] { + lines = append(lines, kv[0]+"="+kv[1]) + } + } + + out := strings.Join(lines, "\n") + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out +} + +// writeEnvFileAtomic writes .env through a sibling temp file and renames it into +// place, mirroring Node's updateEnvFile (env-vars.ts:75-79). A crash mid-write +// therefore leaves the previous file intact rather than a truncated one. +func writeEnvFileAtomic(path, content string) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { // #nosec G306 -- read by the container user + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +// readEnvFileRaw returns the contents of <envdir>/.env, or "" when absent. +func readEnvFileRaw(dir string) (string, error) { + b, err := os.ReadFile(filepath.Join(dir, ".env")) // #nosec G304 -- fixed name in our own data dir + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(b), nil +} + +// managedEnvKeys is the set of keys vip-next owns in .env. Derived from the +// rendered managed block so the key list has exactly one definition +// (compose.RenderEnvFile) rather than a literal copy that can drift. +func managedEnvKeys() map[string]bool { + keys := make(map[string]bool) + for _, kv := range parseManagedBlock(compose.RenderEnvFile(compose.View{})) { + keys[kv[0]] = true + } + return keys +} + +// parseEnvValue ports parseEnvValue (env-vars.ts:14): strip matching double or +// single quotes and unescape. Bare values are returned as-is. +func parseEnvValue(value string) string { + if len(value) >= 2 && strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { + inner := value[1 : len(value)-1] + var b strings.Builder + for i := 0; i < len(inner); i++ { + if inner[i] != '\\' || i+1 >= len(inner) { + b.WriteByte(inner[i]) + continue + } + switch c := inner[i+1]; c { + case '"', '$', '\\': + b.WriteByte(c) + i++ + case 'n': + b.WriteByte('\n') + i++ + case 'r': + b.WriteByte('\r') + i++ + case 't': + b.WriteByte('\t') + i++ + default: + b.WriteByte(inner[i]) + } + } + return b.String() + } + if len(value) >= 2 && strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`) { + return strings.ReplaceAll(value[1:len(value)-1], `\'`, `'`) + } + return value +} + +// quoteEnvValue ports quoteEnvValue (env-vars.ts:41): always double-quote, +// escaping backslash, quote, dollar and the three whitespace escapes. +func quoteEnvValue(value string) string { + r := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `$`, `\$`, + "\n", `\n`, + "\r", `\r`, + "\t", `\t`, + ) + return `"` + r.Replace(value) + `"` +} + +// parseUserEnvVars returns the user-visible variables in raw .env content: +// comments and blanks dropped (Node's preparseEnvData), values unquoted, and +// the Go-managed LANDO_HOST_* keys excluded — those are ours, and Node's own +// env file never contains them. +func parseUserEnvVars(raw string) map[string]string { + managed := managedEnvKeys() + out := make(map[string]string) + for _, line := range preparseEnvData(raw) { + k, v := splitEnvLine(line) + if k == "" || managed[k] { + continue + } + out[k] = parseEnvValue(v) + } + return out +} + +// preparseEnvData ports preparseEnvData (env-vars.ts:7): split on \r?\n, trim, +// drop blank and #-comment lines. +func preparseEnvData(data string) []string { + var out []string + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(strings.TrimSuffix(line, "\r")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + return out +} + +// setEnvVarLine rewrites raw .env content so name is bound to value, replacing +// the existing definition where it stands or appending one. Unrelated lines — +// including comments, which Node's own envvar set discards — are preserved. +func setEnvVarLine(raw, name, value string) string { + quoted := quoteEnvValue(value) + lines, trailing := splitEnvLines(raw) + replaced := false + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if k, _ := splitEnvLine(trimmed); k == name && !replaced { + lines[i] = name + "=" + quoted + replaced = true + } + } + if !replaced { + lines = append(lines, name+"="+quoted) + } + return joinEnvLines(lines, trailing) +} + +// deleteEnvVarLine removes every line defining name from raw .env content and +// reports whether any line matched — Node's `removed` flag +// (src/bin/vip-dev-env-envvar-delete.js:39-49), which decides between a rewrite +// and an exit-1 warning. +func deleteEnvVarLine(raw, name string) (string, bool) { + lines, trailing := splitEnvLines(raw) + kept := make([]string, 0, len(lines)) + removed := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + if k, _ := splitEnvLine(trimmed); k == name { + removed = true + continue + } + } + kept = append(kept, line) + } + return joinEnvLines(kept, trailing), removed +} + +func splitEnvLines(raw string) (lines []string, hadTrailingNewline bool) { + if raw == "" { + return nil, true + } + lines = strings.Split(raw, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + return lines[:len(lines)-1], true + } + return lines, false +} + +func joinEnvLines(lines []string, hadTrailingNewline bool) string { + out := strings.Join(lines, "\n") + if out != "" && (hadTrailingNewline || !strings.HasSuffix(out, "\n")) { + out += "\n" + } + return out +} diff --git a/internal/devenv/envvar.go b/internal/devenv/envvar.go new file mode 100644 index 000000000..05f1eb47d --- /dev/null +++ b/internal/devenv/envvar.go @@ -0,0 +1,165 @@ +package devenv + +import ( + "path/filepath" + "sort" + + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// dev-env env vars live in <envdir>/.env — the same file, in the same format, +// that the Node CLI uses. +// +// DECISION (cutover-visible; see the parity review's blocker B3). vip-next used +// to keep these in instance_data.json while Node kept them in .env, so a +// variable set with one CLI was silently invisible to the other. Of the two +// ways out — Go adopts .env, or Go migrates .env into instance_data.json — the +// evidence points one way: +// +// - .env is not merely Node's storage, it is the delivery mechanism on BOTH +// sides: Node's Lando template declares `env_file: - .env` +// (assets/dev-env.lando.template.yml.ejs:2) and Go's php service already +// declares the same (compose/services.go). A variable in .env reaches the +// container under either CLI with no further plumbing. +// - Both CLIs already resolve the same directory (paths.EnvironmentPath is +// byte-identical to Node's getEnvironmentPath), so this is a shared file by +// construction, not by coincidence. +// - Node reads ONLY .env (env-vars.ts:69) and has never read +// instance_data.json. Migrating in the other direction would leave .env a +// file Node still honours and Go ignores — i.e. the same silent divergence, +// just reversed. +// +// MIGRATION SEMANTICS. Variables previously written by vip-next into +// instance_data.json are copied into .env on the first envvar read or write, +// then cleared from instance_data.json. Details: +// +// - .env wins on conflict. It is the shared, current source of truth, and a +// stale instance_data.json value must never overwrite one the user just set +// with either CLI. +// - Clearing is what makes deletes stick: a leftover legacy map would +// resurrect a variable the user had deleted on the next migration pass. +// - The migration is one-way but does NOT strand anyone. Node never read +// instance_data.json, so nothing it relies on is removed; it gains the +// variables it previously could not see. An older vip-next still consumes +// .env through the php service's env_file, so a downgrade keeps working — +// though an older build's Materialize would overwrite .env on the next +// start, which is the very bug this change removes. +// - LANDO_HOST_USER_ID / LANDO_HOST_GROUP_ID are vip-next-managed, not user +// variables: they are written by Materialize (docker compose substitutes +// ${LANDO_HOST_USER_ID} from .env) and are filtered out of get/list/get-all. + +// envFilePath returns <envdir>/.env for a slug. +func envFilePath(slug string) string { + return filepath.Join(paths.EnvironmentPath(slug), ".env") +} + +// loadEnvFile returns the raw .env contents for an env, first folding in any +// variables left behind in instance_data.json by an older vip-next. Reading +// instance data first also preserves the previous "environment not found" +// error for a slug that does not exist. +func loadEnvFile(slug string) (string, error) { + d, err := instancedata.Read(slug) + if err != nil { + return "", err + } + raw, err := readEnvFileRaw(paths.EnvironmentPath(slug)) + if err != nil { + return "", err + } + if len(d.EnvVars) == 0 { + return raw, nil + } + + // Legacy vars from instance_data.json: add only those .env does not already + // define, then clear the legacy map so a deleted variable cannot come back. + present := parseUserEnvVars(raw) + names := make([]string, 0, len(d.EnvVars)) + for k := range d.EnvVars { + names = append(names, k) + } + sort.Strings(names) // deterministic file ordering + for _, k := range names { + if _, ok := present[k]; !ok { + raw = setEnvVarLine(raw, k, d.EnvVars[k]) + } + } + if err := writeEnvFileAtomic(envFilePath(slug), raw); err != nil { + return "", err + } + d.EnvVars = nil + if err := instancedata.Write(slug, d); err != nil { + return "", err + } + return raw, nil +} + +// mutateEnvFile loads .env (migrating legacy vars), applies fn to the raw +// contents, and writes the result back atomically. +func mutateEnvFile(slug string, fn func(raw string) string) error { + raw, err := loadEnvFile(slug) + if err != nil { + return err + } + return writeEnvFileAtomic(envFilePath(slug), fn(raw)) +} + +// EnvVarSet sets a per-env variable (applied on the next start/rebuild). +func EnvVarSet(slug, name, value string) error { + return mutateEnvFile(slug, func(raw string) string { return setEnvVarLine(raw, name, value) }) +} + +// EnvVarDelete removes a per-env variable. The bool reports whether the +// variable was actually there. +// +// Node's delete bin tracks the same flag and, when nothing matched, writes a +// warning to stderr and sets process.exitCode = 1 *without* calling +// updateEnvFile (src/bin/vip-dev-env-envvar-delete.js:51-54) — so a miss must +// leave .env byte-for-byte alone as well as fail. +func EnvVarDelete(slug, name string) (bool, error) { + raw, err := loadEnvFile(slug) + if err != nil { + return false, err + } + out, removed := deleteEnvVarLine(raw, name) + if !removed { + return false, nil + } + if err := writeEnvFileAtomic(envFilePath(slug), out); err != nil { + return false, err + } + return true, nil +} + +// EnvVarGet returns a single variable. +func EnvVarGet(slug, name string) (string, bool, error) { + raw, err := loadEnvFile(slug) + if err != nil { + return "", false, err + } + v, ok := parseUserEnvVars(raw)[name] + return v, ok, nil +} + +// EnvVarGetAll returns all variables (never nil). +func EnvVarGetAll(slug string) (map[string]string, error) { + raw, err := loadEnvFile(slug) + if err != nil { + return nil, err + } + return parseUserEnvVars(raw), nil +} + +// EnvVarList returns the sorted variable names. +func EnvVarList(slug string) ([]string, error) { + vars, err := EnvVarGetAll(slug) + if err != nil { + return nil, err + } + names := make([]string, 0, len(vars)) + for k := range vars { + names = append(names, k) + } + sort.Strings(names) + return names, nil +} diff --git a/internal/devenv/envvar_test.go b/internal/devenv/envvar_test.go new file mode 100644 index 000000000..f64b984eb --- /dev/null +++ b/internal/devenv/envvar_test.go @@ -0,0 +1,178 @@ +package devenv + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/paths" +) + +func seedEnv(t *testing.T, slug string) { + t.Helper() + if err := instancedata.Write(slug, &instancedata.InstanceData{SiteSlug: slug, Multisite: []byte("false")}); err != nil { + t.Fatal(err) + } +} + +func TestEnvVarSetGetDelete(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + + if err := EnvVarSet("e", "FOO", "bar"); err != nil { + t.Fatal(err) + } + v, ok, err := EnvVarGet("e", "FOO") + if err != nil || !ok || v != "bar" { + t.Fatalf("EnvVarGet = %q,%v,%v want bar,true,nil", v, ok, err) + } + all, err := EnvVarGetAll("e") + if err != nil || all["FOO"] != "bar" { + t.Fatalf("EnvVarGetAll = %v, %v", all, err) + } + names, err := EnvVarList("e") + if err != nil || len(names) != 1 || names[0] != "FOO" { + t.Fatalf("EnvVarList = %v, %v", names, err) + } + if removed, err := EnvVarDelete("e", "FOO"); err != nil || !removed { + t.Fatalf("EnvVarDelete = %v, %v; want removed", removed, err) + } + _, ok, _ = EnvVarGet("e", "FOO") + if ok { + t.Fatal("FOO should be deleted") + } +} + +func TestEnvVarListSorted(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + _ = EnvVarSet("e", "B", "2") + _ = EnvVarSet("e", "A", "1") + names, _ := EnvVarList("e") + if len(names) != 2 || names[0] != "A" || names[1] != "B" { + t.Fatalf("EnvVarList not sorted: %v", names) + } +} + +// writeNodeEnvFile simulates `vip dev-env envvar set` run by the Node CLI: +// it writes <envdir>/.env in Node's format (env-vars.ts quoteEnvValue). +func writeNodeEnvFile(t *testing.T, slug, content string) { + t.Helper() + dir := paths.EnvironmentPath(slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(content), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } +} + +func readEnvFileForTest(t *testing.T, slug string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(paths.EnvironmentPath(slug), ".env")) // #nosec G304 + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// Parity blocker B3, second half: the two CLIs used different backends +// entirely. Node reads <envdir>/.env; Go read instance_data.json. Set a +// variable with one CLI and the other silently saw nothing. +func TestEnvVarReadsVariablesSetByNodeCLI(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + writeNodeEnvFile(t, "e", "# a comment\nMY_TOKEN=\"s3cr3t\"\nPLAIN=value\n") + + v, ok, err := EnvVarGet("e", "MY_TOKEN") + if err != nil { + t.Fatal(err) + } + if !ok || v != "s3cr3t" { + t.Errorf("EnvVarGet(MY_TOKEN) = %q,%v; want s3cr3t,true — a variable set by the Node CLI is invisible to vip-next", v, ok) + } + names, err := EnvVarList("e") + if err != nil { + t.Fatal(err) + } + if len(names) != 2 || names[0] != "MY_TOKEN" || names[1] != "PLAIN" { + t.Errorf("EnvVarList = %v; want [MY_TOKEN PLAIN]", names) + } +} + +// ...and the reverse direction: a variable set by vip-next must land in the +// file the Node CLI reads, quoted the way Node quotes it. +func TestEnvVarSetIsVisibleToNodeCLI(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + if err := EnvVarSet("e", "API_KEY", "abc123"); err != nil { + t.Fatal(err) + } + got := readEnvFileForTest(t, "e") + if !strings.Contains(got, `API_KEY="abc123"`) { + t.Errorf(".env does not carry the variable in Node's format:\n%s", got) + } +} + +// Setting or deleting a variable must not disturb the user's other lines. +func TestEnvVarSetPreservesOtherLines(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + writeNodeEnvFile(t, "e", "# keep me\nEXISTING=\"one\"\nLANDO_HOST_USER_ID=1000\n") + + if err := EnvVarSet("e", "ADDED", "two"); err != nil { + t.Fatal(err) + } + if removed, err := EnvVarDelete("e", "EXISTING"); err != nil || !removed { + t.Fatalf("EnvVarDelete = %v, %v; want removed", removed, err) + } + got := readEnvFileForTest(t, "e") + if !strings.Contains(got, "LANDO_HOST_USER_ID=1000") { + t.Errorf("managed key lost:\n%s", got) + } + if !strings.Contains(got, `ADDED="two"`) { + t.Errorf("new variable missing:\n%s", got) + } + if strings.Contains(got, "EXISTING=") { + t.Errorf("deleted variable still present:\n%s", got) + } +} + +// The Go-managed LANDO_HOST_* keys are ours, not the user's: they must not show +// up in the envvar surface (Node's env file never contains them). +func TestEnvVarListHidesManagedKeys(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + seedEnv(t, "e") + writeNodeEnvFile(t, "e", "LANDO_HOST_USER_ID=1000\nLANDO_HOST_GROUP_ID=1000\nREAL=\"x\"\n") + + names, err := EnvVarList("e") + if err != nil { + t.Fatal(err) + } + if len(names) != 1 || names[0] != "REAL" { + t.Errorf("EnvVarList = %v; want [REAL] — LANDO_HOST_* are managed by vip-next, not user variables", names) + } +} + +// Migration: variables written by an earlier vip-next into instance_data.json +// must be carried into .env on first touch, never silently dropped. +func TestEnvVarMigratesLegacyInstanceDataVars(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", + Multisite: []byte("false"), + EnvVars: map[string]string{"LEGACY": "kept"}, + }); err != nil { + t.Fatal(err) + } + + v, ok, err := EnvVarGet("e", "LEGACY") + if err != nil { + t.Fatal(err) + } + if !ok || v != "kept" { + t.Errorf("EnvVarGet(LEGACY) = %q,%v; want kept,true — legacy instance_data.json vars must migrate, not vanish", v, ok) + } +} diff --git a/internal/devenv/hostops/context.go b/internal/devenv/hostops/context.go new file mode 100644 index 000000000..a3f509190 --- /dev/null +++ b/internal/devenv/hostops/context.go @@ -0,0 +1,38 @@ +package hostops + +import ( + "os" + "runtime" + "strings" +) + +// ctxKind is the elevation/hosts strategy for the current runtime. +type ctxKind int + +const ( + // ctxUnix: macOS / native Linux — edit /etc/hosts via `sudo /bin/sh`. + ctxUnix ctxKind = iota + // ctxWindows: native Windows OR Linux-inside-WSL — edit the WINDOWS hosts + // file + Windows cert store via `powershell.exe Start-Process -Verb RunAs`. + // WSL targets Windows because the user's browser (on Windows) reads the + // Windows hosts file; WSL's /etc/hosts is regenerated from it. + ctxWindows +) + +// resolveContext maps (GOOS, /proc/version contents, WSL_DISTRO_NAME) to a ctxKind. +// procVersion/wslDistro are injected for testability. +func resolveContext(goos, procVersion, wslDistro string) ctxKind { + if goos == "windows" { + return ctxWindows + } + if goos == "linux" && (wslDistro != "" || strings.Contains(strings.ToLower(procVersion), "microsoft")) { + return ctxWindows + } + return ctxUnix +} + +// currentContext resolves the live runtime context. +func currentContext() ctxKind { + pv, _ := os.ReadFile("/proc/version") + return resolveContext(runtime.GOOS, string(pv), os.Getenv("WSL_DISTRO_NAME")) +} diff --git a/internal/devenv/hostops/context_test.go b/internal/devenv/hostops/context_test.go new file mode 100644 index 000000000..fb784726d --- /dev/null +++ b/internal/devenv/hostops/context_test.go @@ -0,0 +1,24 @@ +package hostops + +import "testing" + +func TestResolveContext(t *testing.T) { + cases := []struct { + goos, procVersion string + want ctxKind + }{ + {"darwin", "", ctxUnix}, + {"linux", "Linux version 6.1.0-generic", ctxUnix}, + {"linux", "Linux version 5.15.90.1-microsoft-standard-WSL2", ctxWindows}, + {"windows", "", ctxWindows}, + } + for _, c := range cases { + if got := resolveContext(c.goos, c.procVersion, ""); got != c.want { + t.Fatalf("resolveContext(%q,%q)=%v want %v", c.goos, c.procVersion, got, c.want) + } + } + // WSL_DISTRO_NAME env is an alternate WSL marker. + if got := resolveContext("linux", "", "Ubuntu"); got != ctxWindows { + t.Fatalf("WSL via env not detected: %v", got) + } +} diff --git a/internal/devenv/hostops/e2e_gate_test.go b/internal/devenv/hostops/e2e_gate_test.go new file mode 100644 index 000000000..d285b840a --- /dev/null +++ b/internal/devenv/hostops/e2e_gate_test.go @@ -0,0 +1,17 @@ +//go:build devenv_e2e + +package hostops + +import ( + "os" + "testing" + + "github.com/Automattic/vip/internal/devenv/e2esafety" +) + +func TestMain(m *testing.M) { + if e2esafety.Skip(os.Getenv, os.Stdout) { + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/internal/devenv/hostops/e2e_test.go b/internal/devenv/hostops/e2e_test.go new file mode 100644 index 000000000..eb141d335 --- /dev/null +++ b/internal/devenv/hostops/e2e_test.go @@ -0,0 +1,429 @@ +//go:build devenv_e2e + +// Package hostops e2e harness — the Plan 3 manual integration gate (Task 11). +// +// This is NOT a normal unit test: it drives the REAL proxy + hostops Go code +// against a live Docker daemon and performs the two host-privileged operations +// (trusting the local CA in the System keychain + editing /etc/hosts) under a +// single macOS admin prompt. Because there is no `vip dev-env` command wired to +// these packages yet (Plans 4/5), this harness is the only way to exercise the +// real code paths end-to-end before that wiring lands. +// +// It is gated behind the `devenv_e2e` build tag so it never runs in CI or a +// normal `go test ./...`. Run it explicitly on a macOS machine with Docker: +// +// go test -tags devenv_e2e -run TestProxyHostopsE2E -v \ +// -timeout 5m ./internal/devenv/hostops/ +// +// You will be asked for your admin password ONCE (setup: trust CA + add the +// /etc/hosts entry) and ONCE more at teardown (untrust + remove the entry). +// +// What it proves: +// - proxy.EnsureNetwork / Ensure (real Docker bind + fallback ports) +// - proxy.EnsureCA / EnsureCert (embedded gen-certs.sh in a one-shot) +// - proxy.ExtractCA (docker cp the CA PEM to the host) +// - hostops.Apply: ONE elevation does both trust + /etc/hosts (production path) +// - a plain `curl https://example.vipdev.lndo.site[:port]/` succeeds with +// ssl_verify_result=0 — i.e. SYSTEM trust + /etc/hosts both work, no +// --cacert/--resolve crutches. +// +// Everything it creates is named with the production proxy names and removed in +// teardown (the proxy container, the throwaway nginx backend, the shared +// network, and the certs/proxy_config volumes). +package hostops + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/e2esafety" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +const ( + e2eDomain = compose.DefaultDomain // vipdev.lndo.site + e2eHost = "example." + e2eDomain // example.vipdev.lndo.site + e2eWeb = "vip-dev-env-e2e-web" // throwaway backend container + e2eCertCN = e2eHost + e2eBasename = "example" + + resourceBackendContainer = "backend-container" + resourceProxyContainer = "proxy-container" + resourceProxyNetwork = "proxy-network" + resourceCertsVolume = "certs-volume" + resourceConfigVolume = "config-volume" + resourceTrustedCA = "trusted-ca" + resourceManagedHosts = "managed-hosts" + resourceCAHostFile = "ca-host-file" + resourcePortsState = "ports-state-file" +) + +func TestProxyHostopsE2E(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skipf("e2e trust path is macOS-only; GOOS=%s", runtime.GOOS) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found in PATH") + } + + ctx := context.Background() + r := &dockercli.Runner{} // tees child output to os.Stdout/os.Stderr (Log nil) + + before := captureE2ESnapshot(t, ctx, r) + if err := before.RequireClean(); err != nil { + t.Fatal(err) + } + owned := e2esafety.Snapshot{} + t.Cleanup(func() { teardownOwned(t, ctx, r, owned) }) + + // 1. Shared network + proxy container (real Docker bind + fallback ports). + if err := proxy.EnsureNetwork(ctx, r); err != nil { + t.Fatalf("EnsureNetwork: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceProxyNetwork) + ports, err := proxy.Ensure(ctx, r, proxy.EnsureOptions{Domain: e2eDomain}) + if err != nil { + t.Fatalf("proxy.Ensure: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), + resourceProxyContainer, resourceCertsVolume, + resourceConfigVolume, resourcePortsState) + t.Logf("proxy bound: http=%d https=%d (note: ListenProbe cannot bind <1024 as "+ + "non-root, so 80/443 are pre-skipped to fallbacks unless run as root)", ports.HTTP, ports.HTTPS) + if ports.HTTPS == 0 { + t.Fatalf("no https port chosen: %+v", ports) + } + + // 2. CA + per-env leaf cert (SANs incl. the wildcard, mirroring CertSANs). + if err := proxy.EnsureCA(ctx, r); err != nil { + t.Fatalf("EnsureCA: %v", err) + } + if err := proxy.EnsureCert(ctx, r, proxy.CertRequest{ + Basename: e2eBasename, + CommonName: e2eCertCN, + SANs: []string{e2eHost, "*." + e2eDomain, "localhost"}, + }); err != nil { + t.Fatalf("EnsureCert: %v", err) + } + + // 3. Extract the CA PEM to the host (what hostops.Apply will trust). + caPath, err := proxy.ExtractCA(ctx, r, proxy.CAHostPath()) + if err != nil { + t.Fatalf("ExtractCA: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceCAHostFile) + t.Logf("extracted CA -> %s", caPath) + + // 4. Throwaway nginx backend with the Plan-2 secured-router labels. + if err := r.Docker(ctx, append([]string{ + "run", "-d", "--name", e2eWeb, "--network", compose.ProxyNetwork, + }, webLabels()...)...); err != nil { + t.Fatalf("start backend: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceBackendContainer) + + // 5. THE PRODUCTION ONE-ELEVATION PATH: trust CA + add /etc/hosts in one prompt. + t.Log(">>> macOS will now prompt for your admin password ONCE (trust CA + /etc/hosts) <<<") + if err := Apply(PrivilegedPlan{ + GOOS: runtime.GOOS, + CAPath: caPath, + HostsAdd: []string{e2eHost}, + }); err != nil { + t.Fatalf("hostops.Apply (one-elevation trust+hosts): %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceTrustedCA, resourceManagedHosts) + + // 6. Verify the privileged state landed. + assertKeychainHasCA(t) + assertEtcHostsHas(t, e2eHost) + + // 7. Plain HTTPS through the system trust store + /etc/hosts (no crutches). + url := "https://" + e2eHost + if ports.HTTPS != 443 { + url += ":" + strconv.Itoa(ports.HTTPS) + } + assertHTTPSTrusted(t, url) +} + +// webLabels returns the secured (https/tls) Traefik router labels for the +// throwaway nginx backend (port 80), matching compose/labels.go's scheme for +// id "nginx-example". +func webLabels() []string { + const id = "nginx-example" + rule := "HostRegexp(`" + e2eHost + "`)" + kv := map[string]string{ + "traefik.enable": "true", + "traefik.http.routers." + id + "-secured.entrypoints": "https", + "traefik.http.routers." + id + "-secured.rule": rule, + "traefik.http.routers." + id + "-secured.tls": "true", + "traefik.http.routers." + id + "-secured.service": id + "-secured-service", + "traefik.http.services." + id + "-secured-service.loadbalancer.server.port": "80", + } + var out []string + for k, v := range kv { + out = append(out, "--label", k+"="+v) + } + out = append(out, "nginx:alpine") + return out +} + +// assertHTTPSTrusted polls curl (system trust, no --cacert/--resolve) until the +// TLS handshake verifies the cert against the trusted CA. The key signal is +// ssl_verify_result=0; the HTTP status only needs to be non-000 (a route +// reached a backend), since traefik may take a moment to register the router. +func assertHTTPSTrusted(t *testing.T, url string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + var last string + for time.Now().Before(deadline) { + out, _ := exec.Command("curl", "--noproxy", "*", "-sS", "-o", "/dev/null", + "-w", "%{http_code} %{ssl_verify_result}", url).CombinedOutput() + last = strings.TrimSpace(string(out)) + fields := strings.Fields(last) + if len(fields) == 2 && fields[1] == "0" && fields[0] != "000" { + t.Logf("HTTPS OK (system-trusted): %s -> http=%s ssl_verify=0", url, fields[0]) + return + } + time.Sleep(2 * time.Second) + } + t.Fatalf("HTTPS via system trust did not verify within timeout: %s (last: %q)", url, last) +} + +func assertKeychainHasCA(t *testing.T) { + t.Helper() + out, err := exec.Command("security", "find-certificate", "-c", "WPVIP Local CA", + "/Library/Keychains/System.keychain").CombinedOutput() + if err != nil { + t.Fatalf("CA not found in System keychain after Apply: %v\n%s", err, out) + } + t.Log("CA present in System keychain ✓") +} + +func assertEtcHostsHas(t *testing.T, host string) { + t.Helper() + out, err := exec.Command("grep", "-F", host, etcHosts).CombinedOutput() + if err != nil || !strings.Contains(string(out), host) { + t.Fatalf("/etc/hosts missing %q after Apply: %v\n%s", host, err, out) + } + t.Logf("/etc/hosts has %s -> 127.0.0.1 ✓", host) +} + +func captureE2ESnapshot(t *testing.T, ctx context.Context, r *dockercli.Runner) e2esafety.Snapshot { + t.Helper() + return e2esafety.Snapshot{ + resourceBackendContainer: dockerObjectIdentity(t, ctx, r, "container", e2eWeb, "{{.Id}}"), + resourceProxyContainer: dockerObjectIdentity(t, ctx, r, "container", proxy.ProxyContainerName, "{{.Id}}"), + resourceProxyNetwork: dockerObjectIdentity(t, ctx, r, "network", compose.ProxyNetwork, "{{.Id}}"), + resourceCertsVolume: dockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyCertsVolume, "{{.Name}}|{{.CreatedAt}}"), + resourceConfigVolume: dockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyConfigVolume, "{{.Name}}|{{.CreatedAt}}"), + resourceTrustedCA: trustedCAIdentity(t), + resourceManagedHosts: managedHostsIdentity(t, etcHosts), + resourceCAHostFile: fileIdentity(t, proxy.CAHostPath()), + resourcePortsState: fileIdentity(t, proxy.PortsStatePath()), + } +} + +func dockerObjectIdentity(t *testing.T, ctx context.Context, r *dockercli.Runner, kind, name, format string) string { + t.Helper() + out, err := r.DockerOut(ctx, kind, "inspect", "--format", format, name) + if err == nil { + identity := strings.TrimSpace(string(out)) + if identity == "" { + t.Fatalf("docker %s inspect returned an empty identity for %q", kind, name) + } + return identity + } + + listFormat := "{{.Name}}" + var listed []byte + var listErr error + if kind == "container" { + listFormat = "{{.Names}}" + listed, listErr = r.DockerOut(ctx, kind, "ls", "--all", "--filter", "name="+name, "--format", listFormat) + } else { + listed, listErr = r.DockerOut(ctx, kind, "ls", "--filter", "name="+name, "--format", listFormat) + } + if listErr != nil { + t.Fatalf("docker %s lookup for %q failed after inspect error: %v", kind, name, listErr) + } + for _, candidate := range strings.Split(strings.TrimSpace(string(listed)), "\n") { + if candidate == name { + t.Fatalf("docker %s %q exists but its identity could not be inspected: %v", kind, name, err) + } + } + return "" +} + +func trustedCAIdentity(t *testing.T) string { + t.Helper() + out, err := exec.Command("security", "find-certificate", "-a", "-c", "WPVIP Local CA", "-p", + "/Library/Keychains/System.keychain").CombinedOutput() + if err != nil { + if strings.Contains(string(out), "could not be found") { + return "" + } + t.Fatalf("read trusted WPVIP Local CA: %v: %s", err, strings.TrimSpace(string(out))) + } + block, rest := pem.Decode(out) + if block == nil { + t.Fatal("trusted WPVIP Local CA is not valid PEM") + } + if len(strings.TrimSpace(string(rest))) != 0 { + t.Fatal("multiple WPVIP Local CA certificates found; refusing ambiguous ownership") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse trusted WPVIP Local CA: %v", err) + } + return hashIdentity(cert.Raw) +} + +func managedHostsIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read hosts file %s: %v", path, err) + } + content := string(b) + if strings.Count(content, beginMarker) != strings.Count(content, endMarker) { + t.Fatalf("malformed managed hosts block in %s", path) + } + if strings.Count(content, beginMarker) > 1 { + t.Fatalf("multiple managed hosts blocks found in %s; refusing ambiguous ownership", path) + } + start := strings.Index(content, beginMarker) + end := strings.Index(content, endMarker) + if start < 0 && end < 0 { + return "" + } + if start < 0 || end < start { + t.Fatalf("malformed managed hosts block in %s", path) + } + end += len(endMarker) + if end < len(content) && content[end] == '\n' { + end++ + } + return hashIdentity([]byte(content[start:end])) +} + +func fileIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return hashIdentity(b) +} + +func hashIdentity(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func recordOwned(t *testing.T, owned, current e2esafety.Snapshot, names ...string) { + t.Helper() + for _, name := range names { + if current[name] == "" { + t.Fatalf("created resource %s has no identity", name) + } + owned[name] = current[name] + } +} + +func teardownOwned(t *testing.T, ctx context.Context, r *dockercli.Runner, owned e2esafety.Snapshot) { + t.Helper() + current := captureE2ESnapshot(t, ctx, r) + + removeDocker := func(key string, args ...string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if owned[key] != "" { + t.Logf("%s identity changed; refusing removal (manual cleanup may be required)", key) + } + return + } + if err := r.Docker(ctx, args...); err != nil { + t.Logf("remove owned %s: %v", key, err) + } + } + removeDocker(resourceBackendContainer, "rm", "-f", e2eWeb) + removeDocker(resourceProxyContainer, "rm", "-f", proxy.ProxyContainerName) + removeDocker(resourceConfigVolume, "volume", "rm", proxy.ProxyConfigVolume) + removeDocker(resourceCertsVolume, "volume", "rm", proxy.ProxyCertsVolume) + removeDocker(resourceProxyNetwork, "network", "rm", compose.ProxyNetwork) + + var scriptLines []string + if e2esafety.CanRemove(owned[resourceTrustedCA], current[resourceTrustedCA]) && + e2esafety.CanRemove(owned[resourceCAHostFile], current[resourceCAHostFile]) { + if argv, err := untrustCommand(runtime.GOOS, proxy.CAHostPath()); err == nil { + scriptLines = append(scriptLines, shellJoin(argv)) + } else { + t.Logf("owned trusted CA cannot be removed automatically: %v", err) + } + } else if owned[resourceTrustedCA] != "" { + t.Log("trusted CA or extracted CA identity changed; refusing untrust (manual cleanup may be required)") + } + if e2esafety.CanRemove(owned[resourceManagedHosts], current[resourceManagedHosts]) { + scriptLines = append(scriptLines, stripBlockScript()) + } else if owned[resourceManagedHosts] != "" { + t.Log("managed hosts identity changed; refusing removal (manual cleanup may be required)") + } + if len(scriptLines) > 0 { + t.Log(">>> sudo will prompt once to remove only identity-matched privileged state <<<") + if err := runElevatedScript("#!/bin/sh\nset -e\n" + strings.Join(scriptLines, "\n") + "\n"); err != nil { + t.Logf("owned privileged teardown failed: %v", err) + } + } + + removeOwnedFile := func(key, path string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if owned[key] != "" { + t.Logf("%s identity changed; refusing file removal (manual cleanup may be required)", key) + } + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Logf("remove owned %s: %v", key, err) + } + } + removeOwnedFile(resourceCAHostFile, proxy.CAHostPath()) + removeOwnedFile(resourcePortsState, proxy.PortsStatePath()) +} + +// runElevatedScript runs a /bin/sh script once under a single sudo prompt. +// Test-only mirror of Apply's exec, used for teardown (which must untrust — +// something Apply/PrivilegedPlan does not model). +func runElevatedScript(script string) error { + f, err := os.CreateTemp("", "vip-dev-env-e2e-teardown-*.sh") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + cmd := exec.Command("sudo", "/bin/sh", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/devenv/hostops/elevate.go b/internal/devenv/hostops/elevate.go new file mode 100644 index 000000000..6b2856328 --- /dev/null +++ b/internal/devenv/hostops/elevate.go @@ -0,0 +1,248 @@ +package hostops + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// etcHosts is the real hosts file the elevated script rewrites (as root). The +// unit-tested EnsureHosts/RemoveHosts in hosts.go are path-injected; the +// privileged path uses this fixed location. +const etcHosts = "/etc/hosts" + +// PrivilegedPlan describes the host-privileged operations to run under a single +// elevation: trusting the CA (CAPath) and/or rewriting the managed /etc/hosts +// block (HostsAdd) or removing it (HostsRemove). +type PrivilegedPlan struct { + GOOS string + CAPath string + // HostsAdd is the list of hostnames to write into the managed /etc/hosts block. + // WARNING: wildcard hostnames (e.g. *.example.test) are valid TLS SANs but are + // NOT valid /etc/hosts entries — the resolver ignores them. Callers (Plan 4) + // must filter wildcard SANs out of CertSANs before passing them here. + HostsAdd []string + HostsRemove bool +} + +// shellQuote single-quotes s for safe POSIX-sh interpolation. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellJoin shell-quotes each argv element and joins them with spaces. +func shellJoin(argv []string) string { + q := make([]string, len(argv)) + for i, a := range argv { + q[i] = shellQuote(a) + } + return strings.Join(q, " ") +} + +// stripBlockScript emits sh that removes the managed block from /etc/hosts via a +// temp file, overwriting through a redirect (not mv) so the file keeps its +// existing ownership/permissions. +func stripBlockScript() string { + return fmt.Sprintf(`__vip_tmp="$(mktemp)" +sed -e '/^%s$/,/^%s$/d' '%s' > "$__vip_tmp" +cat "$__vip_tmp" > '%s' +rm -f "$__vip_tmp" +`, beginMarker, endMarker, etcHosts, etcHosts) +} + +// buildPrivilegedScript returns a single /bin/sh program performing all of the +// plan's privileged operations, to be run once under elevation. Trust runs +// first; the /etc/hosts rewrite happens in-script (as root). Returns an error +// if the OS is unsupported for trust or a hostname is invalid. +func buildPrivilegedScript(plan PrivilegedPlan) (string, error) { + var b strings.Builder + b.WriteString("#!/bin/sh\nset -e\n") + if plan.CAPath != "" { + argv, err := trustCommand(plan.GOOS, plan.CAPath) + if err != nil { + return "", err + } + b.WriteString(shellJoin(argv) + "\n") + } + // HostsAdd takes precedence over HostsRemove when both are set. + switch { + case len(plan.HostsAdd) > 0: + if err := validateHosts(plan.HostsAdd); err != nil { + return "", err + } + b.WriteString(stripBlockScript()) + b.WriteString(fmt.Sprintf("cat >> %s <<'__VIP_HOSTS_EOF__'\n", etcHosts)) + b.WriteString(renderBlock(plan.HostsAdd)) + b.WriteString("__VIP_HOSTS_EOF__\n") + case plan.HostsRemove: + b.WriteString(stripBlockScript()) + } + return b.String(), nil +} + +// windowsHostsPath is the hosts file PowerShell edits (from native Windows or +// WSL via powershell.exe). $env:SystemRoot expands at runtime. +const windowsHostsPath = `$env:SystemRoot\System32\drivers\etc\hosts` + +// buildWindowsScript returns a PowerShell program that (optionally) trusts the +// CA via certutil and rewrites the managed block in the Windows hosts file. +// Mirrors buildPrivilegedScript but for the Windows target (native + WSL). +func buildWindowsScript(plan PrivilegedPlan) (string, error) { + var b strings.Builder + b.WriteString("$ErrorActionPreference = 'Stop'\n") + if plan.CAPath != "" { + b.WriteString(fmt.Sprintf("certutil -addstore -f Root %s\n", psQuote(plan.CAPath))) + } + switch { + case len(plan.HostsAdd) > 0: + if err := validateHosts(plan.HostsAdd); err != nil { + return "", err + } + b.WriteString(fmt.Sprintf("$hf = \"%s\"\n", windowsHostsPath)) + b.WriteString("$lines = if (Test-Path $hf) { Get-Content $hf } else { @() }\n") + b.WriteString(fmt.Sprintf("$out = @(); $in = $false\nforeach ($l in $lines) { if ($l.Trim() -eq %s) { $in = $true; continue }; if ($l.Trim() -eq %s) { $in = $false; continue }; if (-not $in) { $out += $l } }\n", psQuote(beginMarker), psQuote(endMarker))) + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote(beginMarker))) + for _, h := range plan.HostsAdd { + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote("127.0.0.1 "+h))) + } + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote(endMarker))) + b.WriteString("Set-Content -Path $hf -Value $out -Encoding ASCII\n") + case plan.HostsRemove: + b.WriteString(fmt.Sprintf("$hf = \"%s\"\n", windowsHostsPath)) + b.WriteString("if (Test-Path $hf) { $lines = Get-Content $hf; $out = @(); $in = $false\n") + b.WriteString(fmt.Sprintf("foreach ($l in $lines) { if ($l.Trim() -eq %s) { $in = $true; continue }; if ($l.Trim() -eq %s) { $in = $false; continue }; if (-not $in) { $out += $l } }\n", psQuote(beginMarker), psQuote(endMarker))) + b.WriteString("Set-Content -Path $hf -Value $out -Encoding ASCII }\n") + } + return b.String(), nil +} + +// psQuote single-quotes s for PowerShell (doubling embedded single quotes). +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// planActions describes, in plain language, what a privileged plan will change — +// used to tell the user why a UAC / sudo prompt is about to appear. +func planActions(plan PrivilegedPlan) []string { + var a []string + if plan.CAPath != "" { + a = append(a, "trust the local development HTTPS certificate") + } + switch { + case len(plan.HostsAdd) > 0: + a = append(a, "add local hostnames to your hosts file") + case plan.HostsRemove: + a = append(a, "remove local hostnames from your hosts file") + } + return a +} + +// joinAnd joins phrases into "a", "a and b", or "a, b, and c". +func joinAnd(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " and " + items[1] + default: + return strings.Join(items[:len(items)-1], ", ") + ", and " + items[len(items)-1] + } +} + +// Apply runs the plan's privileged operations under a SINGLE elevation, +// dispatching by runtime context: ctxWindows (native Windows / WSL) edits the +// Windows hosts file + cert store via powershell.exe RunAs; everything else +// (macOS / native Linux) edits /etc/hosts via sudo /bin/sh. +func Apply(plan PrivilegedPlan) error { + if currentContext() == ctxWindows { + return applyWindows(plan) + } + return applyUnix(plan) +} + +// applyWindows writes the PowerShell script to a temp .ps1 and runs it elevated. +// From native Windows AND from WSL, `powershell.exe` is invokable; Start-Process +// -Verb RunAs triggers the UAC prompt and edits the Windows hosts/cert store. +func applyWindows(plan PrivilegedPlan) error { + script, err := buildWindowsScript(plan) + if err != nil { + return err + } + f, err := os.CreateTemp("", "vip-dev-env-priv-*.ps1") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if actions := planActions(plan); len(actions) > 0 { + fmt.Fprintf(os.Stderr, "\nAdministrator access is needed to %s.\nApprove the Windows (UAC) prompt to continue...\n", joinAnd(actions)) + } + inner := fmt.Sprintf("$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','%s'; exit $p.ExitCode", name) + cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", inner) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return err + } + if plan.CAPath != "" { + fmt.Fprintln(os.Stderr, "Local HTTPS certificate trusted. Restart your browser for it to take effect.") + } + return nil +} + +// applyUnix runs the plan's privileged operations under a SINGLE elevation — one +// `sudo /bin/sh <script>` invocation, i.e. one password prompt for both the CA +// trust and the /etc/hosts edit. Not unit-tested (it prompts/execs); exercised +// by the Task 11 integration harness. +// +// We use sudo rather than macOS osascript's "with administrator privileges" +// even on darwin: osascript elevates in a context detached from the login GUI +// session, where `security add-trusted-cert` cannot authorize System-keychain +// trust settings ("SecTrustSettingsSetTrustSettings: the authorization was +// denied since no user interaction was possible") — it adds the cert but leaves +// it untrusted. sudo from the terminal keeps the session context, so the +// Security Agent can authorize the trust change. vip is always run from a +// terminal, so a TTY for the sudo prompt is available. (Validated 2026-06-19; +// see docs/superpowers/notes/2026-06-18-traefik-openssl-cert-contract.md.) +func applyUnix(plan PrivilegedPlan) error { + script, err := buildPrivilegedScript(plan) + if err != nil { + return err + } + f, err := os.CreateTemp("", "vip-dev-env-priv-*.sh") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if actions := planActions(plan); len(actions) > 0 { + fmt.Fprintf(os.Stderr, "\nAdministrator access is needed to %s.\nYou may be prompted for your password...\n", joinAnd(actions)) + } + cmd := exec.Command("sudo", "/bin/sh", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + if plan.CAPath != "" { + fmt.Fprintln(os.Stderr, "Local HTTPS certificate trusted. Restart your browser for it to take effect.") + } + return nil +} diff --git a/internal/devenv/hostops/elevate_message_test.go b/internal/devenv/hostops/elevate_message_test.go new file mode 100644 index 000000000..acb1ed43e --- /dev/null +++ b/internal/devenv/hostops/elevate_message_test.go @@ -0,0 +1,35 @@ +package hostops + +import "testing" + +func TestPlanActions(t *testing.T) { + if a := planActions(PrivilegedPlan{}); len(a) != 0 { + t.Fatalf("empty plan => no actions, got %v", a) + } + if a := planActions(PrivilegedPlan{CAPath: "/x/ca.pem", HostsAdd: []string{"a.test"}}); len(a) != 2 { + t.Fatalf("trust+hosts => 2 actions, got %v", a) + } + if a := planActions(PrivilegedPlan{CAPath: "/x/ca.pem"}); len(a) != 1 || a[0] != "trust the local development HTTPS certificate" { + t.Fatalf("trust-only actions wrong: %v", a) + } + if a := planActions(PrivilegedPlan{HostsRemove: true}); len(a) != 1 { + t.Fatalf("remove => 1 action, got %v", a) + } +} + +func TestJoinAnd(t *testing.T) { + cases := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{"one"}, "one"}, + {[]string{"one", "two"}, "one and two"}, + {[]string{"one", "two", "three"}, "one, two, and three"}, + } + for _, c := range cases { + if got := joinAnd(c.in); got != c.want { + t.Fatalf("joinAnd(%v) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/devenv/hostops/elevate_test.go b/internal/devenv/hostops/elevate_test.go new file mode 100644 index 000000000..ddcf50331 --- /dev/null +++ b/internal/devenv/hostops/elevate_test.go @@ -0,0 +1,111 @@ +package hostops + +import ( + "strings" + "testing" +) + +func TestBuildPrivilegedScriptIncludesBothOps(t *testing.T) { + plan := PrivilegedPlan{ + GOOS: "darwin", + CAPath: "/x/ca.pem", + HostsAdd: []string{"example.test"}, + } + script, err := buildPrivilegedScript(plan) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(script, "add-trusted-cert") { + t.Fatalf("script missing trust op:\n%s", script) + } + if !strings.Contains(script, "example.test") || !strings.Contains(script, "/etc/hosts") { + t.Fatalf("script missing hosts op:\n%s", script) + } + // the script must be a single set -e shell program + if !strings.HasPrefix(script, "#!/bin/sh") { + t.Fatalf("script should be a /bin/sh program:\n%s", script) + } +} + +func TestBuildPrivilegedScriptTrustOnly(t *testing.T) { + script, err := buildPrivilegedScript(PrivilegedPlan{GOOS: "darwin", CAPath: "/x/ca.pem"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(script, "/etc/hosts") { + t.Fatalf("no hosts ops requested; script should not touch /etc/hosts:\n%s", script) + } +} + +func TestBuildPrivilegedScriptRemoveOnly(t *testing.T) { + script, err := buildPrivilegedScript(PrivilegedPlan{GOOS: "darwin", HostsRemove: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(script, "/etc/hosts") || !strings.Contains(script, beginMarker) { + t.Fatalf("remove plan should strip the managed block:\n%s", script) + } + if strings.Contains(script, "add-trusted-cert") { + t.Fatalf("no CAPath given; script should not trust a cert:\n%s", script) + } +} + +func TestBuildPrivilegedScriptUnsupportedOSErrors(t *testing.T) { + if _, err := buildPrivilegedScript(PrivilegedPlan{GOOS: "plan9", CAPath: "/x/ca.pem"}); err == nil { + t.Fatal("expected error: trust not supported on plan9") + } +} + +func TestBuildPrivilegedScriptRejectsBadHostname(t *testing.T) { + _, err := buildPrivilegedScript(PrivilegedPlan{GOOS: "darwin", HostsAdd: []string{"bad host\nattacker"}}) + if err == nil { + t.Fatal("expected error for hostname with embedded whitespace/newline") + } +} + +func TestBuildPrivilegedScriptAddBeatsRemove(t *testing.T) { + script, err := buildPrivilegedScript(PrivilegedPlan{GOOS: "darwin", HostsAdd: []string{"a.test"}, HostsRemove: true}) + if err != nil { + t.Fatal(err) + } + // HostsAdd path writes the block (append heredoc); it must include the host. + if !strings.Contains(script, "127.0.0.1 a.test") { + t.Fatalf("HostsAdd should win when both set:\n%s", script) + } +} + +func TestBuildPowerShellScriptWritesWindowsHostsAndTrust(t *testing.T) { + ps, err := buildWindowsScript(PrivilegedPlan{CAPath: `C:\tmp\ca.pem`, HostsAdd: []string{"demo.vipdev.site"}}) + if err != nil { + t.Fatalf("buildWindowsScript: %v", err) + } + for _, want := range []string{ + `certutil`, `-addstore`, `Root`, `ca.pem`, + `drivers\etc\hosts`, + beginMarker, endMarker, "127.0.0.1 demo.vipdev.site", + } { + if !strings.Contains(ps, want) { + t.Fatalf("powershell script missing %q:\n%s", want, ps) + } + } +} + +func TestBuildWindowsScriptRejectsBadHost(t *testing.T) { + if _, err := buildWindowsScript(PrivilegedPlan{HostsAdd: []string{"bad host"}}); err == nil { + t.Fatal("expected error for hostname with whitespace") + } +} + +func TestShellQuote(t *testing.T) { + cases := map[string]string{ + "": "''", + "abc": "'abc'", + "a'b": `'a'\''b'`, + "/a b/c": "'/a b/c'", + } + for in, want := range cases { + if got := shellQuote(in); got != want { + t.Errorf("shellQuote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/devenv/hostops/hosts.go b/internal/devenv/hostops/hosts.go new file mode 100644 index 000000000..e34eb1385 --- /dev/null +++ b/internal/devenv/hostops/hosts.go @@ -0,0 +1,255 @@ +// Package hostops performs the two host-privileged operations a vip dev +// environment needs — trusting the local CA in the system trust store and +// editing the managed /etc/hosts block — and runs them under a SINGLE privilege +// elevation (spec §11). Plan 4 calls hostops.Apply(PrivilegedPlan) once: it +// builds one /bin/sh script doing both ops and runs it behind one sudo prompt +// (sudo, not macOS osascript — osascript's detached context can't authorize the +// System-keychain trust change; see Apply). The block format and trust/elevation +// command construction are pure and unit-tested; the elevated run is exercised +// by the integration checklist. RenderHostsBlock previews the block; EnsureHosts +// /RemoveHosts are the path-injected reference editors used in tests. +package hostops + +import ( + "fmt" + "os" + "strings" +) + +const ( + beginMarker = "# BEGIN vip-dev-env" + endMarker = "# END vip-dev-env" +) + +// renderBlock builds the managed hosts block for the given hostnames. +func renderBlock(hosts []string) string { + var b strings.Builder + b.WriteString(beginMarker + "\n") + for _, h := range hosts { + b.WriteString("127.0.0.1 " + h + "\n") + } + b.WriteString(endMarker + "\n") + return b.String() +} + +// validateHosts rejects hostnames that would corrupt the hosts file (embedded +// whitespace/newlines could inject unmanaged entries when written as root). +func validateHosts(hosts []string) error { + for _, h := range hosts { + if h == "" || strings.ContainsAny(h, " \t\r\n") { + return fmt.Errorf("hostops: invalid hostname %q", h) + } + } + return nil +} + +// stripBlock returns content with the managed block removed. It errors on a +// malformed file (a begin marker without a matching end marker) rather than +// silently discarding everything to EOF, which would lose unmanaged content. +func stripBlock(content string) (string, error) { + lines := strings.Split(content, "\n") + var out []string + inBlock := false + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if trimmed == beginMarker { + inBlock = true + continue + } + if trimmed == endMarker { + inBlock = false + continue + } + if !inBlock { + out = append(out, ln) + } + } + if inBlock { + return "", fmt.Errorf("hostops: malformed hosts file: %s without matching %s", beginMarker, endMarker) + } + return strings.Join(out, "\n"), nil +} + +// EnsureHosts writes (or replaces) the managed block in the hosts file at path, +// mapping each hostname to 127.0.0.1. Idempotent; preserves other content. An +// empty hosts slice removes the managed block (delegates to RemoveHosts). +func EnsureHosts(path string, hosts []string) error { + if len(hosts) == 0 { + return RemoveHosts(path) + } + if err := validateHosts(hosts); err != nil { + return err + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + stripped, err := stripBlock(string(b)) + if err != nil { + return err + } + stripped = strings.TrimRight(stripped, "\n") + block := renderBlock(hosts) + updated := block + if stripped != "" { + updated = stripped + "\n" + block + } + return os.WriteFile(path, []byte(updated), 0o644) +} + +// HostsPresent reports whether the managed block in the real /etc/hosts already +// contains every hostname in hosts — a non-privileged read so Start can skip the +// sudo elevation when the entries are unchanged. An empty list is trivially +// present; an unreadable file reports false (fall back to elevating). +func HostsPresent(hosts []string) bool { + if len(hosts) == 0 { + return true + } + path := etcHosts + if currentContext() == ctxWindows { + path = windowsHostsReadPath() + } + b, err := os.ReadFile(path) + if err != nil { + return false + } + return hostsPresentIn(string(b), hosts) +} + +// ManagedHostsMatch reports whether the managed block contains exactly hosts. +// Unlike HostsPresent, it also detects stale extra entries, so callers that own +// the complete global snapshot can safely decide whether a rewrite is needed. +func ManagedHostsMatch(hosts []string) bool { + path := etcHosts + if currentContext() == ctxWindows { + path = windowsHostsReadPath() + } + b, err := os.ReadFile(path) + if err != nil { + return false + } + return managedHostsMatchIn(string(b), hosts) +} + +// windowsHostsReadPath returns a non-privileged readable path to the Windows +// hosts file: the drvfs mount under WSL, else the native Windows path. +func windowsHostsReadPath() string { + if _, err := os.Stat("/mnt/c/Windows/System32/drivers/etc/hosts"); err == nil { + return "/mnt/c/Windows/System32/drivers/etc/hosts" + } + if sr := os.Getenv("SystemRoot"); sr != "" { + return sr + `\System32\drivers\etc\hosts` + } + return `C:\Windows\System32\drivers\etc\hosts` +} + +// hostsPresentIn is the pure core of HostsPresent: it reports whether the +// managed block within content lists every requested hostname. +func hostsPresentIn(content string, hosts []string) bool { + have := map[string]bool{} + inBlock := false + for _, ln := range strings.Split(content, "\n") { + t := strings.TrimSpace(ln) + switch { + case t == beginMarker: + inBlock = true + case t == endMarker: + inBlock = false + case inBlock: + fields := strings.Fields(t) + if len(fields) < 2 { + continue + } + for _, name := range fields[1:] { // skip the leading IP + have[name] = true + } + } + } + for _, h := range hosts { + if !have[h] { + return false + } + } + return true +} + +func managedHostsMatchIn(content string, hosts []string) bool { + if validateHosts(hosts) != nil { + return false + } + want := map[string]bool{} + for _, host := range hosts { + want[host] = true + } + have := map[string]bool{} + inBlock := false + sawBegin := false + sawEnd := false + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + switch trimmed { + case beginMarker: + if inBlock || sawBegin { + return false + } + inBlock = true + sawBegin = true + case endMarker: + if !inBlock || sawEnd { + return false + } + inBlock = false + sawEnd = true + default: + if !inBlock { + continue + } + fields := strings.Fields(trimmed) + if len(fields) < 2 { + continue + } + for _, name := range fields[1:] { + have[name] = true + } + } + } + if inBlock || sawBegin != sawEnd { + return false + } + if len(want) == 0 && !sawBegin { + return true + } + if len(have) != len(want) { + return false + } + for host := range want { + if !have[host] { + return false + } + } + return true +} + +// RenderHostsBlock returns the managed /etc/hosts block (begin/end markers and +// the 127.0.0.1 mappings) for the given hostnames, without touching any file — +// for Plan 4 to preview what Apply will write under elevation. +func RenderHostsBlock(hosts []string) string { + return renderBlock(hosts) +} + +// RemoveHosts strips the managed block from the hosts file at path. +func RemoveHosts(path string) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + stripped, err := stripBlock(string(b)) + if err != nil { + return err + } + stripped = strings.TrimRight(stripped, "\n") + if stripped == "" { + return os.WriteFile(path, []byte(""), 0o644) + } + return os.WriteFile(path, []byte(stripped+"\n"), 0o644) +} diff --git a/internal/devenv/hostops/hosts_test.go b/internal/devenv/hostops/hosts_test.go new file mode 100644 index 000000000..f26b13c5f --- /dev/null +++ b/internal/devenv/hostops/hosts_test.go @@ -0,0 +1,178 @@ +package hostops + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureHostsAddsBlockIdempotently(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + hosts := []string{"example.test", "foo.example.test"} + if err := EnsureHosts(p, hosts); err != nil { + t.Fatal(err) + } + // second call must not duplicate the block + if err := EnsureHosts(p, hosts); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + got := string(b) + if c := strings.Count(got, beginMarker); c != 1 { + t.Fatalf("block written %d times, want 1:\n%s", c, got) + } + for _, h := range hosts { + if !strings.Contains(got, "127.0.0.1 "+h) { + t.Fatalf("missing entry for %s:\n%s", h, got) + } + } + if !strings.Contains(got, "127.0.0.1 localhost") { + t.Fatalf("clobbered existing content:\n%s", got) + } +} + +func TestEnsureHostsUpdatesBlock(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"old.test"}); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"new.test"}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + got := string(b) + if strings.Contains(got, "old.test") { + t.Fatalf("stale entry not replaced:\n%s", got) + } + if !strings.Contains(got, "new.test") { + t.Fatalf("new entry missing:\n%s", got) + } +} + +func TestRemoveHostsStripsBlock(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"x.test"}); err != nil { + t.Fatal(err) + } + if err := RemoveHosts(p); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + got := string(b) + if strings.Contains(got, beginMarker) || strings.Contains(got, "x.test") { + t.Fatalf("block not removed:\n%s", got) + } + if !strings.Contains(got, "127.0.0.1 localhost") { + t.Fatalf("clobbered existing content:\n%s", got) + } +} + +func TestEnsureHostsEmptyFileNoLeadingNewline(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"a.test"}); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + if strings.HasPrefix(string(b), "\n") { + t.Fatalf("output must not start with a blank line:\n%q", string(b)) + } + if !strings.HasPrefix(string(b), beginMarker) { + t.Fatalf("expected block at start of empty file:\n%q", string(b)) + } +} + +func TestEnsureHostsEmptySliceRemovesBlock(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"x.test"}); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, nil); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(p) + got := string(b) + if strings.Contains(got, beginMarker) || strings.Contains(got, "x.test") { + t.Fatalf("empty slice should remove the block:\n%s", got) + } + if !strings.Contains(got, "127.0.0.1 localhost") { + t.Fatalf("clobbered existing content:\n%s", got) + } +} + +func TestEnsureHostsRejectsBadHostname(t *testing.T) { + p := filepath.Join(t.TempDir(), "hosts") + if err := os.WriteFile(p, []byte("127.0.0.1 localhost\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := EnsureHosts(p, []string{"evil host\n1.2.3.4 attacker"}); err == nil { + t.Fatal("expected error for hostname with embedded whitespace/newline") + } +} + +func TestStripBlockUnclosedErrors(t *testing.T) { + _, err := stripBlock("127.0.0.1 localhost\n" + beginMarker + "\n127.0.0.1 a.test\n") + if err == nil { + t.Fatal("expected error for begin marker without matching end marker") + } +} + +func TestRenderHostsBlockPreview(t *testing.T) { + got := RenderHostsBlock([]string{"a.test", "b.test"}) + if !strings.Contains(got, beginMarker) || !strings.Contains(got, endMarker) { + t.Fatalf("preview missing markers:\n%s", got) + } + for _, h := range []string{"127.0.0.1 a.test", "127.0.0.1 b.test"} { + if !strings.Contains(got, h) { + t.Fatalf("preview missing %q:\n%s", h, got) + } + } +} + +func TestHostsPresentIn(t *testing.T) { + content := "127.0.0.1 localhost\n" + beginMarker + "\n127.0.0.1 a.test\n127.0.0.1 b.test\n" + endMarker + "\n" + if !hostsPresentIn(content, []string{"a.test", "b.test"}) { + t.Fatal("both managed hosts should be present") + } + if hostsPresentIn(content, []string{"a.test", "c.test"}) { + t.Fatal("c.test is not in the managed block") + } + // A host outside the managed block does not count. + if hostsPresentIn("127.0.0.1 outside.test\n", []string{"outside.test"}) { + t.Fatal("hosts outside the managed block must not count as present") + } +} + +func TestManagedHostsMatchInRequiresExactSnapshot(t *testing.T) { + content := "127.0.0.1 localhost\n" + RenderHostsBlock([]string{"a.test", "b.test"}) + if !managedHostsMatchIn(content, []string{"b.test", "a.test"}) { + t.Fatal("same managed hosts in a different order should match") + } + if managedHostsMatchIn(content, []string{"a.test"}) { + t.Fatal("an extra managed hostname must make the snapshot differ") + } + if managedHostsMatchIn(content, []string{"a.test", "b.test", "c.test"}) { + t.Fatal("a missing managed hostname must make the snapshot differ") + } + if managedHostsMatchIn("# BEGIN vip-dev-env\n127.0.0.1 a.test\n", []string{"a.test"}) { + t.Fatal("a malformed managed block must not match") + } + if hostsPresentIn("# BEGIN vip-dev-env\n\n# END vip-dev-env\n", []string{"a.test"}) { + t.Fatal("a blank line in the managed block must not invent a hostname") + } +} diff --git a/internal/devenv/hostops/thumbprint_test.go b/internal/devenv/hostops/thumbprint_test.go new file mode 100644 index 000000000..a5c4ecac8 --- /dev/null +++ b/internal/devenv/hostops/thumbprint_test.go @@ -0,0 +1,57 @@ +package hostops + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha1" // #nosec G505 -- test verifies the SHA-1 thumbprint format, not a security primitive + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestCertThumbprint verifies certThumbprint reads a PEM cert and returns its +// SHA-1 thumbprint as uppercase hex — the identifier the Windows cert store uses. +func TestCertThumbprint(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(42), + Subject: pkix.Name{CommonName: "WPVIP Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv) + if err != nil { + t.Fatal(err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + p := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(p, pemBytes, 0o600); err != nil { + t.Fatal(err) + } + + got, err := certThumbprint(p) + if err != nil { + t.Fatalf("certThumbprint: %v", err) + } + sum := sha1.Sum(der) // #nosec G401 -- test comparison, not security + want := strings.ToUpper(hex.EncodeToString(sum[:])) + if got != want { + t.Fatalf("thumbprint = %q, want %q", got, want) + } + if len(got) != 40 { + t.Fatalf("thumbprint %q is not 40 hex chars", got) + } +} diff --git a/internal/devenv/hostops/trust.go b/internal/devenv/hostops/trust.go new file mode 100644 index 000000000..79a8884d1 --- /dev/null +++ b/internal/devenv/hostops/trust.go @@ -0,0 +1,115 @@ +package hostops + +import ( + "crypto/sha1" // #nosec G505 -- SHA-1 is the Windows certificate thumbprint format, not a security primitive + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "os" + "os/exec" + "strings" +) + +// trustStrat is the CA-trust strategy for the current runtime. +type trustStrat int + +const ( + // trustNone: CA trust is unsupported here (native Linux) — skip it. + trustNone trustStrat = iota + // trustDarwin: macOS system keychain via `security`. + trustDarwin + // trustWindows: Windows Root store via `certutil` (native Windows or WSL). + trustWindows +) + +// trustStrategy picks the trust strategy from the runtime context and GOOS. +// WSL reports GOOS=linux but currentContext()==ctxWindows, so context wins. +func trustStrategy(ctx ctxKind, goos string) trustStrat { + if ctx == ctxWindows { + return trustWindows + } + if goos == "darwin" { + return trustDarwin + } + return trustNone +} + +// CATrusted reports whether caPath's CA is already trusted — a non-privileged +// check so Start can skip the trust elevation (and avoid a re-prompt) when it's +// already trusted. On native Linux, CA trust is unsupported, so it reports true +// ("nothing to do"): Start then writes the hosts block without attempting the +// (impossible) trust. The cert simply isn't browser-trusted on native Linux. +func CATrusted(goos, caPath string) bool { + if caPath == "" { + return false + } + switch trustStrategy(currentContext(), goos) { + case trustWindows: + // certutil addresses a store cert by its thumbprint (SHA-1), NOT by a file + // path: passing caPath yields CRYPT_E_NOT_FOUND whether or not the CA is + // trusted, so CATrusted would never see it as trusted and Start would + // re-elevate on every run. Resolve the thumbprint and verify THAT in Root. + tp, err := certThumbprint(caPath) + if err != nil { + return false // unreadable cert => treat as untrusted; Start will (re)trust + } + return exec.Command("certutil", "-verifystore", "Root", tp).Run() == nil + case trustDarwin: + return exec.Command("security", "verify-cert", "-c", caPath).Run() == nil + default: // trustNone (native Linux) + return true + } +} + +// certThumbprint returns the SHA-1 thumbprint (uppercase hex, no separators) of +// the certificate at caPath — the identifier the Windows cert store / certutil use +// to address a specific cert. caPath is PEM (as ExtractCA writes it); a raw-DER +// fallback keeps it robust. +func certThumbprint(caPath string) (string, error) { + data, err := os.ReadFile(caPath) + if err != nil { + return "", err + } + der := data + if block, _ := pem.Decode(data); block != nil { + der = block.Bytes + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return "", err + } + sum := sha1.Sum(cert.Raw) // #nosec G401 -- SHA-1 is the Windows cert thumbprint algorithm, not used for security + return strings.ToUpper(hex.EncodeToString(sum[:])), nil +} + +// trustCommand returns the argv to add the CA at caPath to the system trust +// store. macOS uses `security add-trusted-cert` against the system keychain +// (requires admin; run under elevation in Task 10). Mirrors Lando's trust step. +func trustCommand(goos, caPath string) ([]string, error) { + switch goos { + case "darwin": + return []string{ + "security", "add-trusted-cert", "-d", "-r", "trustRoot", + "-k", "/Library/Keychains/System.keychain", caPath, + }, nil + case "windows": + // certutil adds the cert to the LocalMachine Root store (idempotent with -f). + return []string{"certutil", "-addstore", "-f", "Root", caPath}, nil + default: + return nil, fmt.Errorf("hostops: CA trust not supported on %s yet", goos) + } +} + +// untrustCommand returns the argv to remove the CA from the trust store. +// It is the teardown counterpart to trustCommand, consumed by the Plan 4 destroy path. +func untrustCommand(goos, caPath string) ([]string, error) { + switch goos { + case "darwin": + return []string{"security", "remove-trusted-cert", "-d", caPath}, nil + case "windows": + return []string{"certutil", "-delstore", "Root", caPath}, nil + default: + return nil, fmt.Errorf("hostops: CA untrust not supported on %s yet", goos) + } +} diff --git a/internal/devenv/hostops/trust_test.go b/internal/devenv/hostops/trust_test.go new file mode 100644 index 000000000..68e083a07 --- /dev/null +++ b/internal/devenv/hostops/trust_test.go @@ -0,0 +1,90 @@ +package hostops + +import ( + "reflect" + "strings" + "testing" +) + +func TestTrustCommandWindowsCertutil(t *testing.T) { + argv, err := trustCommand("windows", `C:\tmp\ca.pem`) + if err != nil { + t.Fatalf("trustCommand windows: %v", err) + } + joined := strings.Join(argv, " ") + if !strings.Contains(joined, "certutil") || !strings.Contains(joined, "Root") { + t.Fatalf("windows trust argv = %v", argv) + } +} + +func TestTrustCommandDarwin(t *testing.T) { + argv, err := trustCommand("darwin", "/Users/me/.local/share/vip/dev-env/proxy/ca.pem") + if err != nil { + t.Fatal(err) + } + want := []string{ + "security", "add-trusted-cert", "-d", "-r", "trustRoot", + "-k", "/Library/Keychains/System.keychain", + "/Users/me/.local/share/vip/dev-env/proxy/ca.pem", + } + if !reflect.DeepEqual(argv, want) { + t.Fatalf("got %v, want %v", argv, want) + } +} + +func TestTrustCommandUnsupported(t *testing.T) { + if _, err := trustCommand("plan9", "/x/ca.pem"); err == nil { + t.Fatal("expected unsupported-OS error") + } +} + +func TestUntrustCommandDarwin(t *testing.T) { + argv, err := untrustCommand("darwin", "/x/ca.pem") + if err != nil { + t.Fatal(err) + } + want := []string{"security", "remove-trusted-cert", "-d", "/x/ca.pem"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("got %v, want %v", argv, want) + } +} + +func TestUntrustCommandUnsupported(t *testing.T) { + if _, err := untrustCommand("plan9", "/x/ca.pem"); err == nil { + t.Fatal("expected unsupported-OS error") + } +} + +func TestTrustStrategy(t *testing.T) { + cases := []struct { + ctx ctxKind + goos string + want trustStrat + }{ + {ctxWindows, "linux", trustWindows}, // WSL + {ctxWindows, "windows", trustWindows}, // native Windows + {ctxUnix, "darwin", trustDarwin}, // macOS + {ctxUnix, "linux", trustNone}, // native Linux: trust unsupported + } + for _, c := range cases { + if got := trustStrategy(c.ctx, c.goos); got != c.want { + t.Fatalf("trustStrategy(%v,%q)=%v want %v", c.ctx, c.goos, got, c.want) + } + } +} + +func TestCATrustedNativeLinuxIsNoOp(t *testing.T) { + // On a unix (non-WSL) host with goos=linux, CA trust is unsupported, so + // CATrusted reports true (skip trust) for a non-empty cert path — no exec. + // NOTE: this test assumes the test host is not WSL (currentContext()==ctxUnix); + // if currentContext() is ctxWindows here, skip. + if currentContext() == ctxWindows { + t.Skip("host resolves as windows/WSL context") + } + if !CATrusted("linux", "/nonexistent/ca.pem") { + t.Fatal("native-linux CATrusted should be a no-op true (skip trust)") + } + if CATrusted("linux", "") { + t.Fatal("empty caPath must be false") + } +} diff --git a/internal/devenv/hostrefresh.go b/internal/devenv/hostrefresh.go new file mode 100644 index 000000000..6e7ea396d --- /dev/null +++ b/internal/devenv/hostrefresh.go @@ -0,0 +1,127 @@ +package devenv + +import ( + "context" + "fmt" + "sort" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/lifecycle" +) + +// HostRefreshDeps separates complete snapshot construction from Docker, +// instance-data, and privileged host-file boundaries. +type HostRefreshDeps struct { + Names func() []string + Read func(string) (*instancedata.InstanceData, error) + Running func(context.Context) (map[string]bool, error) + ListSubsites func(context.Context, string) ([]string, error) + SnapshotMatches func([]string) bool + Apply func(hostops.PrivilegedPlan) error + GOOS string +} + +func sortedUniqueHosts(hosts []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(hosts)) + for _, host := range hosts { + if host == "" || seen[host] { + continue + } + seen[host] = true + out = append(out, host) + } + sort.Strings(out) + return out +} + +func refreshManagedHostsWith(ctx context.Context, deps HostRefreshDeps) error { + if deps.Names == nil || deps.Read == nil || deps.Running == nil || + deps.ListSubsites == nil || deps.SnapshotMatches == nil || deps.Apply == nil { + return fmt.Errorf("devenv: incomplete managed-host refresh dependencies") + } + names := append([]string(nil), deps.Names()...) + sort.Strings(names) + running, err := deps.Running(ctx) + if err != nil { + return fmt.Errorf("read environment running state: %w", err) + } + + var hosts []string + for _, name := range names { + isRunning, known := running[name] + if !known { + return fmt.Errorf("running state is unavailable for local environment %q", name) + } + data, err := deps.Read(name) + if err != nil { + return fmt.Errorf("read local environment %q: %w", name, err) + } + view := compose.NewView(data, compose.Options{Domain: data.Domain}) + hosts = append(hosts, envHosts(view, nil)...) + if !isRunning || !view.MultisiteEnabled || !view.MultisiteSubdomain { + continue + } + domains, err := deps.ListSubsites(ctx, name) + if err != nil { + return fmt.Errorf("discover subsites for running environment %q: %w", name, err) + } + hosts = append(hosts, lifecycle.SubsiteHosts(domains, view)...) + } + hosts = sortedUniqueHosts(hosts) + + // No boundary call occurs before the complete snapshot exists. A failure + // above therefore leaves the current global managed block untouched. + if deps.SnapshotMatches(hosts) { + return nil + } + plan := hostops.PrivilegedPlan{GOOS: deps.GOOS} + if len(hosts) == 0 { + plan.HostsRemove = true + } else { + plan.HostsAdd = hosts + } + return deps.Apply(plan) +} + +func strictRunningMap(ctx context.Context, runner *dockercli.Runner, names []string) (map[string]bool, error) { + docker := dockerAdapter{r: runner} + out := make(map[string]bool, len(names)) + for _, name := range names { + states, err := docker.ComposePS(ctx, name) + if err != nil { + return nil, err + } + out[name] = anyRunning(states) + } + return out, nil +} + +// RefreshManagedHosts rebuilds the one globally-owned hosts block from every +// local environment. Subsites are discovered for every running subdomain +// multisite before any elevation occurs, so a single failed discovery can +// never erase another environment's offline names. +func RefreshManagedHosts(ctx context.Context) error { + runner, err := newRunner(ctx) + if err != nil { + return err + } + names := instancedata.AllNames() + subsites := subsiteAdapter{r: runner} + return refreshManagedHostsWith(ctx, HostRefreshDeps{ + Names: func() []string { return names }, + Read: instancedata.Read, + Running: func(ctx context.Context) (map[string]bool, error) { + return strictRunningMap(ctx, runner, names) + }, + ListSubsites: func(ctx context.Context, name string) ([]string, error) { + return subsites.ListSubsiteDomains(ctx, name, phpService) + }, + SnapshotMatches: hostops.ManagedHostsMatch, + Apply: hostops.Apply, + GOOS: goos(), + }) +} diff --git a/internal/devenv/hostrefresh_test.go b/internal/devenv/hostrefresh_test.go new file mode 100644 index 000000000..90f4bb27c --- /dev/null +++ b/internal/devenv/hostrefresh_test.go @@ -0,0 +1,123 @@ +package devenv + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +type hostRefreshRecorder struct { + data map[string]*instancedata.InstanceData + running map[string]bool + subsites map[string][]string + subsiteErr map[string]error + match bool + matchCalls int + applyCalls int + applied hostops.PrivilegedPlan + discoveryCall []string +} + +func (r *hostRefreshRecorder) deps(names []string) HostRefreshDeps { + return HostRefreshDeps{ + Names: func() []string { return names }, + Read: func(name string) (*instancedata.InstanceData, error) { + data, ok := r.data[name] + if !ok { + return nil, errors.New("missing instance data") + } + return data, nil + }, + Running: func(context.Context) (map[string]bool, error) { + return r.running, nil + }, + ListSubsites: func(_ context.Context, name string) ([]string, error) { + r.discoveryCall = append(r.discoveryCall, name) + return r.subsites[name], r.subsiteErr[name] + }, + SnapshotMatches: func([]string) bool { + r.matchCalls++ + return r.match + }, + Apply: func(plan hostops.PrivilegedPlan) error { + r.applyCalls++ + r.applied = plan + return nil + }, + GOOS: "darwin", + } +} + +func hostRefreshFixture() (*hostRefreshRecorder, []string) { + recorder := &hostRefreshRecorder{ + data: map[string]*instancedata.InstanceData{ + "one": { + SiteSlug: "one", Domain: "vipdev.site", PHPMyAdmin: true, + Multisite: []byte(`"subdomain"`), + }, + "two": { + SiteSlug: "two", Domain: "vipdev.site", Mailpit: true, + Multisite: []byte(`"subdomain"`), + }, + "stopped": { + SiteSlug: "stopped", Domain: "vipdev.site", + Multisite: []byte(`"subdomain"`), + }, + }, + running: map[string]bool{"one": true, "two": true, "stopped": false}, + subsites: map[string][]string{ + "one": {"sub.one.vipdev.site", "foreign.example.com"}, + "two": {"sub.two.vipdev.site", "deep.sub.two.vipdev.site"}, + }, + subsiteErr: map[string]error{}, + } + return recorder, []string{"two", "stopped", "one"} +} + +func TestRefreshManagedHostsBuildsCompleteSortedSnapshot(t *testing.T) { + recorder, names := hostRefreshFixture() + if err := refreshManagedHostsWith(context.Background(), recorder.deps(names)); err != nil { + t.Fatal(err) + } + want := []string{ + "one-pma.vipdev.site", + "one.vipdev.site", + "stopped.vipdev.site", + "sub.one.vipdev.site", + "sub.two.vipdev.site", + "two-mailpit.vipdev.site", + "two.vipdev.site", + } + if recorder.applyCalls != 1 || !reflect.DeepEqual(recorder.applied.HostsAdd, want) { + t.Fatalf("apply calls=%d hosts=%#v, want %#v", recorder.applyCalls, recorder.applied.HostsAdd, want) + } + if got, wantCalls := recorder.discoveryCall, []string{"one", "two"}; !reflect.DeepEqual(got, wantCalls) { + t.Fatalf("subsite discovery = %#v, want %#v", got, wantCalls) + } +} + +func TestRefreshManagedHostsLeavesCurrentBlockUntouchedOnDiscoveryFailure(t *testing.T) { + recorder, names := hostRefreshFixture() + recorder.subsiteErr["two"] = errors.New("wp site list failed") + if err := refreshManagedHostsWith(context.Background(), recorder.deps(names)); err == nil { + t.Fatal("expected discovery error") + } + if recorder.matchCalls != 0 || recorder.applyCalls != 0 { + t.Fatalf("partial snapshot reached host file: match=%d apply=%d", recorder.matchCalls, recorder.applyCalls) + } +} + +func TestRefreshManagedHostsSkipsElevationForExactSnapshot(t *testing.T) { + recorder, names := hostRefreshFixture() + recorder.match = true + if err := refreshManagedHostsWith(context.Background(), recorder.deps(names)); err != nil { + t.Fatal(err) + } + if recorder.matchCalls != 1 || recorder.applyCalls != 0 { + t.Fatalf("match=%d apply=%d", recorder.matchCalls, recorder.applyCalls) + } +} diff --git a/internal/devenv/importdata.go b/internal/devenv/importdata.go new file mode 100644 index 000000000..57affcc4a --- /dev/null +++ b/internal/devenv/importdata.go @@ -0,0 +1,488 @@ +package devenv + +import ( + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/Automattic/vip/internal/appctx" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/searchreplace" +) + +// containerUploadsPath is where WordPress uploads live in the php container. +const containerUploadsPath = "/wp/wp-content/uploads" + +// ImportOptions controls `import sql`. +type ImportOptions struct { + // SearchReplace pairs ("from,to"), applied to the SQL before import. + SearchReplace []string + // InPlace rewrites the source SQL file with the search-replace result + // instead of importing a throwaway copy. + InPlace bool + // Quiet suppresses informational output during import. + Quiet bool + // SkipValidate skips BOTH pre-import checks, exactly as Node's one flag + // does (dev-env-import-sql.ts:83-101): + // 1. the running-environment gate (php + database must be up), and + // 2. the SQL-file validation suite (see importvalidate.go). + // `dev-env sync sql` sets it, matching Node's runImport + // (dev-env-sync-sql.ts:333-336) — the SQL it imports is an export it just + // produced and search-replaced itself. + SkipValidate bool + // SkipReindex skips the post-import Elasticsearch reindex (Node + // `-k/--skip-reindex`, dev-env-import-sql.ts:130). Wired. + SkipReindex bool + // Out receives the informational output Node prints ("Success: Database + // imported.", the data-cleanup warning). Defaults to os.Stdout. + Out io.Writer + // Confirm answers the irreversible --in-place question. Injected by the + // cobra layer so the prompt can see the --non-interactive FLAG and not + // just the VIP_NON_INTERACTIVE env var (this package has no command to + // hand to appctx). nil falls back to the command-less prompt. + Confirm func(message string, defaultYes bool) (bool, error) + // BatchMode pre-confirms the irreversible --in-place rewrite, exactly as + // Node's batchMode does (search-and-replace.ts:151 gates the prompt on + // `inPlace && ! batchMode`). + // + // `dev-env sync sql` sets it. Node never reaches this prompt from sync at + // all: runImport (dev-env-sync-sql.ts:333-338) passes inPlace/skipValidate/ + // quiet/postImportSQL and NO searchReplace, because sync already ran its + // own streaming replacement (:199-208). Go instead hands the pairs to + // ImportSQL, so without this the prompt fires on a temp file the user + // never named — asking a meaningless question interactively, and in CI + // failing *after* the full production export has already been paid for. + BatchMode bool +} + +// searchReplacePairs passes the user's "from,to" pairs through unchanged; +// internal/searchreplace.Run already expects that comma form. +func searchReplacePairs(pairs []string) []string { return pairs } + +// importCopyArgs builds `docker cp <host> <container>:<dest>`. +func importCopyArgs(hostPath, containerID, destPath string) []string { + return []string{"cp", hostPath, containerID + ":" + destPath} +} + +// importMediaCopyArgs builds `docker cp <srcDir>/. <container>:<uploads>` — the +// trailing /. copies the directory's CONTENTS into uploads. +func importMediaCopyArgs(srcDir, containerID string) []string { + // Use explicit string concat — filepath.Join cleans "/." away. + return []string{"cp", srcDir + "/.", containerID + ":" + containerUploadsPath} +} + +// importSQLArgs builds the compose args for the SQL import: +// `exec -T php wp --allow-root db import <path>`. The leading compose binary + +// `-p <slug>` are supplied by Runner.Compose. `-T` disables TTY allocation +// (stdin is a pipe/file, not a terminal). `--allow-root` is required because the +// php container runs as root (Lando ran wp as a non-root user; the Go port does +// not), so wp-cli would otherwise refuse with "YIKES! running as root". +func importSQLArgs(containerPath string) []string { + return []string{"exec", "-T", phpService, "wp", "--allow-root", "db", "import", containerPath} +} + +// myDumperImportArgs builds the compose exec args for importing a MyDumper dump. +// Node's `db-myloader` is a Lando TOOLING alias (assets/dev-env.lando.template.yml.ejs), +// not a wp-cli command: it runs the `myloader` binary (bundled in the php-fpm +// image) against the dev-env database, streaming the dump on stdin. We invoke +// the binary directly in the php service (root, the container default), with the +// same flags getImportArgs appends (dev-env-import-sql.ts:156). +func myDumperImportArgs(sourceDB string, quiet bool, threads int) []string { + args := []string{ + "exec", "-T", phpService, + "myloader", "-h", "database", "-u", "wordpress", "-p", "wordpress", "--database", "wordpress", + // --drop-table (DROP mode by default): myloader 0.21.3 deprecated + // --overwrite-tables (it no longer drops -> "table already exists"). + "--drop-table", + } + if sourceDB != "" { + args = append(args, "--source-db="+sourceDB) + } + args = append(args, + "--threads="+strconv.Itoa(threads), + "--max-threads-for-schema-creation=10", + "--max-threads-for-index-creation=10", + "--skip-triggers", "--skip-post", "--optimize-keys", + "--checksum=SKIP", "--metadata-refresh-interval=2000000", "--stream", + ) + if quiet { + args = append(args, "--verbose=0") + } else { + args = append(args, "--verbose=3") + } + return args +} + +// myDumperThreads mirrors Node's Math.max(os.cpus().length - 2, 1). +func myDumperThreads() int { + if n := runtime.NumCPU() - 2; n > 1 { + return n + } + return 1 +} + +// importRunner is everything the import path needs from dockercli.Runner. +// Depending on the interface (rather than the concrete runner) lets the whole +// import sequence — including Node's post-import steps — be unit-tested with +// no Docker daemon; *dockercli.Runner satisfies it. +type importRunner interface { + composeExecer + Docker(ctx context.Context, args ...string) error + ComposeStdin(ctx context.Context, project string, stdin io.Reader, args ...string) error + ComposeOut(ctx context.Context, project string, args ...string) ([]byte, error) + ComposePS(ctx context.Context, project string) ([]dockercli.ServiceState, error) +} + +// databaseService is the MariaDB service; phpService lives in devexec.go. +const databaseService = "database" + +// requiredRunningServices are the two services Node checks before importing +// (dev-env-import-sql.ts:84-89 and vip-dev-env-sync-sql.js:123-126). +var requiredRunningServices = []string{phpService, databaseService} + +// ErrEnvironmentNotStarted carries Node's exact UserError text +// (dev-env-import-sql.ts:92). Node routes UserError to exit.withError, which +// prints "Error: <message>" and exits 1 — the same thing returning this does. +var ErrEnvironmentNotStarted = errors.New("Environment needs to be started first") + +// EnvironmentIsRunning ports isContainerRunning (dev-environment-lando.ts:1056) +// for the php + database pair: Lando asked Docker for containers labelled with +// the compose project and service and filtered on status "running"; compose's +// own `ps --format json --all` gives us the same information in one call. +// +// A ComposePS error is NOT translated into "needs to be started": that failure +// means Docker itself is unreachable, which the user needs to see verbatim. +func EnvironmentIsRunning(ctx context.Context, r importRunner, slug string) (bool, error) { + states, err := r.ComposePS(ctx, slug) + if err != nil { + return false, err + } + running := make(map[string]bool, len(states)) + for _, s := range states { + if strings.EqualFold(s.State, "running") { + running[s.Service] = true + } + } + for _, svc := range requiredRunningServices { + if !running[svc] { + return false, nil + } + } + return true, nil +} + +func ensureEnvironmentRunning(ctx context.Context, r importRunner, slug string) error { + up, err := EnvironmentIsRunning(ctx, r, slug) + if err != nil { + return err + } + if !up { + return ErrEnvironmentNotStarted + } + return nil +} + +// containerID resolves the php service's container id for `docker cp` via +// `docker compose -p <slug> ps -q <service>`, run (by ComposeOut) from the +// env's materialized directory so compose finds its compose file. +func containerID(ctx context.Context, r importRunner, slug, service string) (string, error) { + out, err := r.ComposeOut(ctx, slug, "ps", "-q", service) + if err != nil { + return "", err + } + id := strings.TrimRight(string(out), "\r\n") + if id == "" { + return "", fmt.Errorf("devenv: no running container for service %q (start the environment first)", service) + } + return id, nil +} + +// ImportSQL imports a SQL file into a running env, optionally search-replacing +// it first. The real docker cp + exec are exercised under the devenv_e2e gate. +func ImportSQL(ctx context.Context, slug, file string, o ImportOptions) error { + // --in-place rewrites the user's own dump irreversibly. Node reaches + // searchAndReplace from here via resolveImportPath (dev-environment-core.ts:854) + // with no batchMode, so its "This operation is not reversible" confirm + // fires — unlike the platform `vip import sql` path, which pre-confirms and + // passes batchMode:true. Gate before anything else runs: no rewrite, no + // Docker call, nothing to undo if the answer is no. Declining exits 0 with + // the file untouched (Node's bare process.exit()); a context that cannot + // prompt is refused rather than silently proceeding or hanging in CI. + if o.InPlace && len(o.SearchReplace) > 0 && !o.BatchMode { + ask := o.Confirm + if ask == nil { + ask = func(message string, defaultYes bool) (bool, error) { + return appctx.Confirm(nil, message, defaultYes) + } + } + approved, err := ask(searchreplace.InPlaceConfirmMessage, false) + if err != nil { + return err + } + if !approved { + return nil + } + } + + r, err := newRunner(ctx) + if err != nil { + return err + } + return importSQL(ctx, r, slug, file, o) +} + +// importSQL is ImportSQL's body, parameterised over the runner so the full +// sequence (import + Node's post-import steps) is testable without Docker. +// The --in-place confirmation is handled by ImportSQL before this is reached. +func importSQL(ctx context.Context, r importRunner, slug, file string, o ImportOptions) error { + out := o.Out + if out == nil { + out = os.Stdout + } + + // Detect the dump type on the ORIGINAL file, before any rewrite or + // decompression — GetSqlDumpDetails reads through gzip transparently. + details, _ := searchreplace.GetSqlDumpDetails(file) + + // Decompress a compressed dump to a plaintext temp file BEFORE using it, + // mirroring Node (dev-env-import-sql.ts:51-73). This is not optional: + // `myloader --stream` needs the raw `-- <file> <len>` stream framing, and + // piping it the gzip bytes instead makes it read zero files and hang + // forever (the loader threads block on work that never arrives). `wp db + // import` likewise needs plaintext SQL. Neither decompresses on its own. + switch comp, derr := dumpCompression(file); { + case derr != nil: + return derr + case comp == "gzip": + if !o.Quiet { + fmt.Fprintf(out, "Extracting the compressed file %s...\n", file) + } + plain, cleanup, derr := decompressDumpToTemp(file) + if derr != nil { + return derr + } + defer cleanup() + file = plain + case comp == "zip": + // Node detects zip as compressed but unzipFile only extracts gzip + // (client-file-uploader.ts:226-236), so a zip import fails the same way. + return fmt.Errorf("Error extracting the SQL file: unsupported file format: application/zip") + } + + isMyDumper := details.Type == searchreplace.DumpTypeMyDumper + + // Node runs the running-environment gate and the SQL validation under ONE + // `if ( ! this.options.skipValidate )` (dev-env-import-sql.ts:83-101), so + // --skip-validate skips both. The gate comes first here — before the + // search-replace rather than after it as in Node — so a stopped environment + // cannot leave the user with a rewritten (--in-place) dump and no import. + if !o.SkipValidate { + if err := ensureEnvironmentRunning(ctx, r, slug); err != nil { + return err + } + } + + // Node's resolveImportPath: apply the file-level search-replace, then + // validate and import the RESULT (dev-env-import-sql.ts:76-96). MyDumper + // dumps are excluded — rewriting one invalidates the per-file byte markers + // `myloader --stream` relies on, so those pairs are applied with + // `wp search-replace` after the import instead (see importMyDumperDump). + resolved := file + if !isMyDumper && len(o.SearchReplace) > 0 { + res, err := searchreplace.Run(file, searchReplacePairs(o.SearchReplace), searchreplace.Options{InPlace: o.InPlace}) + if err != nil { + return err + } + resolved = res.OutputFileName + // --in-place rewrites the original file; otherwise the result lives in + // a throwaway temp dir we clean up once the import is done. + if !o.InPlace { + defer os.RemoveAll(filepath.Dir(res.OutputFileName)) + } + } + + if !o.SkipValidate { + if err := validateDevEnvSQL(sqlValidationInput{ + Path: resolved, + // Node: `${ this.slug }.${ lando.config.domain }` (ts:95). + ExpectedDomain: slug + "." + devEnvDomain(slug), + IsMyDumper: isMyDumper, + HasSearchReplace: len(o.SearchReplace) > 0, + Quiet: o.Quiet, + }, out); err != nil { + return err + } + } + + if isMyDumper { + if err := importMyDumperDump(ctx, r, slug, resolved, details.SourceDB, o); err != nil { + return err + } + } else if err := importMysqldump(ctx, r, slug, resolved); err != nil { + return err + } + + if !o.Quiet { + fmt.Fprintln(out, "Success: Database imported.") + } + + // Node's run() does not end at the import: it flushes the cache, reindexes, + // recreates the `vipgo` admin user and runs the VIP data cleanup + // (dev-env-import-sql.ts:128-142). Skipping them locks the user out of + // their own local wp-admin, because the imported dump replaced the local + // users table with the source environment's. + return postImportSteps(ctx, r, slug, postImportOptions{ + Quiet: o.Quiet, + SkipReindex: o.SkipReindex, + }, out) +} + +// importMysqldump handles a plain mysqldump: `docker cp` the (already +// search-replaced) file into the php container, then `wp db import`. +func importMysqldump(ctx context.Context, r importRunner, slug, src string) error { + cid, err := containerID(ctx, r, slug, phpService) + if err != nil { + return err + } + dest := "/tmp/" + filepath.Base(src) + if err := r.Docker(ctx, importCopyArgs(src, cid, dest)...); err != nil { + return err + } + // Run the import via the compose runner (tees output to terminal + log). + return r.Compose(ctx, slug, importSQLArgs(dest)...) +} + +// importMyDumperDump handles a MyDumper-format dump: stream the dump into +// myloader (it must NOT be file-level search-replaced — that changes content +// lengths and invalidates the per-file byte markers myloader uses to delimit +// the --stream), then search-replace the live DB with wp-cli afterward. +// +// The dump is decompressed upstream in ImportSQL before it gets here. That step +// is mandatory: a gzip-compressed stream piped into `myloader --stream` is read +// as zero files, after which the loader threads block on work that never +// arrives and the import hangs (myloader prints "Intermediate thread: SHUTDOWN" +// and nothing more). Verified end-to-end against a real VIP MyDumper backup on +// container myloader 0.21.3 — so this is the streaming bug, not the container. +func importMyDumperDump(ctx context.Context, r importRunner, slug, file, sourceDB string, o ImportOptions) error { + if err := importMyDumper(ctx, r, slug, file, sourceDB, o.Quiet); err != nil { + return err + } + return wpSearchReplace(ctx, r, slug, o.SearchReplace, o.Quiet) +} + +// wpSearchReplace runs `wp search-replace <from> <to> --all-tables` for each +// "from,to" pair on the live DB (used after a MyDumper import, where the dump +// can't be file-level rewritten). wp-cli handles serialized PHP data correctly. +func wpSearchReplace(ctx context.Context, r importRunner, slug string, pairs []string, quiet bool) error { + for _, p := range pairs { + from, to, ok := strings.Cut(p, ",") + if !ok || from == "" { + continue + } + args := []string{"exec", "-T", phpService, "wp", "--allow-root", "search-replace", from, to, "--all-tables", "--skip-columns=guid"} + if quiet { + args = append(args, "--quiet") + } + if err := r.Compose(ctx, slug, args...); err != nil { + return err + } + } + return nil +} + +// importMyDumper streams a MyDumper dump file into `myloader --stream`. +func importMyDumper(ctx context.Context, r importRunner, slug, file, sourceDB string, quiet bool) error { + f, err := os.Open(file) // #nosec G304 -- CLI/exported dump path + if err != nil { + return err + } + defer f.Close() + return r.ComposeStdin(ctx, slug, f, myDumperImportArgs(sourceDB, quiet, myDumperThreads())...) +} + +// dumpCompression peeks the first bytes of path for a gzip/zip magic number, +// mirroring Node's detectCompressedMimeType (client-file-uploader.ts:565): +// 1f8b → "gzip", 504b0304 → "zip", anything else → "" (uncompressed). Detection +// is by content, not extension, so a misnamed .gz still imports. +func dumpCompression(path string) (string, error) { + f, err := os.Open(path) // #nosec G304 -- CLI/exported dump path + if err != nil { + return "", err + } + defer f.Close() + + hdr := make([]byte, 4) + n, err := io.ReadFull(f, hdr) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return "", err + } + hdr = hdr[:n] + switch { + case len(hdr) >= 2 && hdr[0] == 0x1f && hdr[1] == 0x8b: + return "gzip", nil + case len(hdr) >= 4 && hdr[0] == 0x50 && hdr[1] == 0x4b && hdr[2] == 0x03 && hdr[3] == 0x04: + return "zip", nil + } + return "", nil +} + +// decompressDumpToTemp gunzips src into a fresh temp "sql-import.sql" file and +// returns its path plus a cleanup func (Node extracts to a makeTempDir() path, +// dev-env-import-sql.ts:54-68). The caller defers cleanup. +func decompressDumpToTemp(src string) (path string, cleanup func(), err error) { + in, err := os.Open(src) // #nosec G304 -- CLI/exported dump path + if err != nil { + return "", nil, err + } + defer in.Close() + + zr, err := gzip.NewReader(in) + if err != nil { + return "", nil, fmt.Errorf("Error extracting the SQL file: %s", err.Error()) + } + defer zr.Close() + + dir, err := os.MkdirTemp("", "vip-import-") + if err != nil { + return "", nil, err + } + cleanup = func() { _ = os.RemoveAll(dir) } + + out := filepath.Join(dir, "sql-import.sql") + f, err := os.Create(out) // #nosec G304 -- temp dir we just created + if err != nil { + cleanup() + return "", nil, err + } + // #nosec G110 -- trusted exported/VIP-backup dump, not attacker input. + if _, err := io.Copy(f, zr); err != nil { + _ = f.Close() + cleanup() + return "", nil, fmt.Errorf("Error extracting the SQL file: %s", err.Error()) + } + if err := f.Close(); err != nil { + cleanup() + return "", nil, err + } + return out, cleanup, nil +} + +// ImportMedia copies a local media directory's contents into the env uploads. +func ImportMedia(ctx context.Context, slug, srcDir string) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + cid, err := containerID(ctx, r, slug, phpService) + if err != nil { + return err + } + return r.Docker(ctx, importMediaCopyArgs(srcDir, cid)...) +} diff --git a/internal/devenv/importdata_test.go b/internal/devenv/importdata_test.go new file mode 100644 index 000000000..91ce76d0e --- /dev/null +++ b/internal/devenv/importdata_test.go @@ -0,0 +1,142 @@ +package devenv + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/appctx" +) + +// Parity blocker B2. Node's dev-env import sql reaches searchAndReplace through +// resolveImportPath (dev-environment-core.ts:854) WITHOUT batchMode, so the +// "This operation is not reversible" confirm fires — unlike the platform +// `vip import sql` path, which passes batchMode:true (vip-import-sql.js:732) +// because it has already confirmed. vip-next rewrote the user's dump with no +// prompt on either dev-env path. +// +// The test process has no TTY, so the gate must refuse before anything is +// rewritten — and before Docker is contacted, which is also why this test can +// run without a container. +func TestImportSQLInPlaceRequiresConfirmation(t *testing.T) { + src := filepath.Join(t.TempDir(), "dump.sql") + const original = "-- MySQL dump\nCREATE TABLE a;\n" + if err := os.WriteFile(src, []byte(original), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + err := ImportSQL(context.Background(), "e", src, ImportOptions{ + SearchReplace: []string{"from,to"}, + InPlace: true, + }) + if !errors.Is(err, appctx.ErrNonInteractive) { + t.Errorf("err = %v; want appctx.ErrNonInteractive (the in-place confirm must gate the rewrite)", err) + } + got, readErr := os.ReadFile(src) // #nosec G304 + if readErr != nil { + t.Fatal(readErr) + } + if string(got) != original { + t.Errorf("dump was rewritten without confirmation:\n got %q\nwant %q", got, original) + } +} + +// BatchMode is Node's own pre-confirm (search-and-replace.ts:151 gates on +// `inPlace && ! batchMode`). `dev-env sync sql` sets it because the file is a +// temp export, not anything the user named — Node never even reaches this +// prompt from sync, since runImport passes no searchReplace at all +// (dev-env-sync-sql.ts:333-338). +// +// Without this, sync asks a meaningless question interactively and in CI fails +// with ErrNonInteractive *after* the whole production export has been paid for. +func TestImportSQLBatchModeSkipsInPlaceConfirmation(t *testing.T) { + src := filepath.Join(t.TempDir(), "dump.sql") + if err := os.WriteFile(src, []byte("-- MySQL dump\nCREATE TABLE a;\n"), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + err := ImportSQL(context.Background(), "e", src, ImportOptions{ + SearchReplace: []string{"from,to"}, + InPlace: true, + SkipValidate: true, + BatchMode: true, + Confirm: func(string, bool) (bool, error) { + t.Error("BatchMode must pre-confirm; the prompt fired anyway") + return false, nil + }, + }) + // It must get PAST the confirm. It then fails for an unrelated reason + // (no Docker in a unit test), which is the point: not ErrNonInteractive. + if errors.Is(err, appctx.ErrNonInteractive) { + t.Errorf("err = %v; BatchMode must not reach the interactive gate", err) + } +} + +// Without --in-place nothing irreversible happens, so there must be no prompt: +// the run proceeds (and then fails for an unrelated, non-confirmation reason). +func TestImportSQLWithoutInPlaceDoesNotConfirm(t *testing.T) { + src := filepath.Join(t.TempDir(), "dump.sql") + if err := os.WriteFile(src, []byte("-- MySQL dump\nCREATE TABLE a;\n"), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + err := ImportSQL(context.Background(), "e", src, ImportOptions{SearchReplace: []string{"from,to"}}) + if errors.Is(err, appctx.ErrNonInteractive) { + t.Error("no --in-place: Node does not prompt here, so vip-next must not either") + } +} + +func TestImportSQLArgs(t *testing.T) { + // --allow-root: the php container runs as root, so wp-cli refuses without it. + got := importSQLArgs("/tmp/in/file.sql") + want := []string{"exec", "-T", "php", "wp", "--allow-root", "db", "import", "/tmp/in/file.sql"} + assertArgv(t, got, want) +} + +func TestMyDumperImportArgs(t *testing.T) { + // db-myloader is a Lando tooling alias for the myloader binary (run in the + // php service), NOT a wp-cli command. + got := myDumperImportArgs("wordpress", false, 4) + want := []string{ + "exec", "-T", "php", + "myloader", "-h", "database", "-u", "wordpress", "-p", "wordpress", "--database", "wordpress", + "--drop-table", "--source-db=wordpress", "--threads=4", + "--max-threads-for-schema-creation=10", "--max-threads-for-index-creation=10", + "--skip-triggers", "--skip-post", "--optimize-keys", "--checksum=SKIP", + "--metadata-refresh-interval=2000000", "--stream", "--verbose=3", + } + assertArgv(t, got, want) +} + +func TestMyDumperImportArgsNoSourceDBQuiet(t *testing.T) { + got := myDumperImportArgs("", true, 1) + joined := strings.Join(got, " ") + if strings.Contains(joined, "--source-db") { + t.Fatalf("empty source db must omit --source-db: %v", got) + } + if !strings.Contains(joined, "--threads=1") || !strings.Contains(joined, "--verbose=0") { + t.Fatalf("quiet/threads not applied: %v", got) + } +} + +func TestImportSQLCopyArgs(t *testing.T) { + got := importCopyArgs("/host/file.sql", "containerid", "/tmp/file.sql") + want := []string{"cp", "/host/file.sql", "containerid:/tmp/file.sql"} + assertArgv(t, got, want) +} + +func TestImportMediaCopyArgs(t *testing.T) { + // Trailing /. copies the directory CONTENTS into uploads (not a nested dir). + got := importMediaCopyArgs("/host/uploads", "cid") + want := []string{"cp", "/host/uploads/.", "cid:/wp/wp-content/uploads"} + assertArgv(t, got, want) +} + +func TestSearchReplacePairsFromFlag(t *testing.T) { + got := searchReplacePairs([]string{"old.com,new.test", "a,b"}) + if len(got) != 2 || got[0] != "old.com,new.test" { + t.Fatalf("pairs = %v", got) + } +} diff --git a/internal/devenv/importdecompress_test.go b/internal/devenv/importdecompress_test.go new file mode 100644 index 000000000..2dedb41e8 --- /dev/null +++ b/internal/devenv/importdecompress_test.go @@ -0,0 +1,90 @@ +package devenv + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +// writeGzip writes data gzip-compressed to path. +func writeGzip(t *testing.T, path string, data []byte) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zw := gzip.NewWriter(f) + if _, err := zw.Write(data); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } +} + +// TestDumpCompression pins the magic-byte detection (Node's +// detectCompressedMimeType: 1f8b→gzip, 504b0304→zip, else uncompressed). +func TestDumpCompression(t *testing.T) { + dir := t.TempDir() + + plain := filepath.Join(dir, "plain.sql") + if err := os.WriteFile(plain, []byte("-- metadata.header 10\nhello"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := dumpCompression(plain); err != nil || got != "" { + t.Fatalf("plain: got %q err %v, want \"\"", got, err) + } + + gz := filepath.Join(dir, "dump.sql.gz") + writeGzip(t, gz, []byte("-- metadata.header 10\nhello")) + if got, err := dumpCompression(gz); err != nil || got != "gzip" { + t.Fatalf("gzip: got %q err %v, want \"gzip\"", got, err) + } + + zip := filepath.Join(dir, "dump.zip") + if err := os.WriteFile(zip, []byte{0x50, 0x4b, 0x03, 0x04, 0x00}, 0o600); err != nil { + t.Fatal(err) + } + if got, err := dumpCompression(zip); err != nil || got != "zip" { + t.Fatalf("zip: got %q err %v, want \"zip\"", got, err) + } +} + +// TestDecompressDumpToTemp is the regression test for the import hang: a gzipped +// MyDumper stream MUST be decompressed to its plaintext `-- <file> <len>` +// framing before it reaches myloader --stream. Feeding raw gzip bytes makes +// myloader read zero files and hang. +func TestDecompressDumpToTemp(t *testing.T) { + dir := t.TempDir() + // Realistic MyDumper stream framing. + content := []byte("\n-- metadata.header 5\nabcde\n-- t-schema-create.sql 3\nxyz") + gz := filepath.Join(dir, "dump.sql.gz") + writeGzip(t, gz, content) + + path, cleanup, err := decompressDumpToTemp(gz) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, content) { + t.Fatalf("decompressed mismatch:\n got %q\nwant %q", got, content) + } + // The whole point: output must be the plaintext stream, NOT gzip magic. + if len(got) >= 2 && got[0] == 0x1f && got[1] == 0x8b { + t.Fatalf("decompressed output still starts with gzip magic") + } + + // cleanup removes the temp artifact. + cleanup() + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("cleanup did not remove temp file %s (err=%v)", path, statErr) + } +} diff --git a/internal/devenv/importgate_test.go b/internal/devenv/importgate_test.go new file mode 100644 index 000000000..b7a27f9e6 --- /dev/null +++ b/internal/devenv/importgate_test.go @@ -0,0 +1,166 @@ +package devenv + +import ( + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/dockercli" +) + +// seedImportEnv writes instance data with no domain, so instancedata.Read +// backfills LegacyDomain — the environment therefore serves this host. +const testEnvDomain = "e.vipdev.lndo.site" + +// validDump is a dump that satisfies every FATAL check for env "e". +func validDump(t *testing.T) string { + t.Helper() + return writeSQL(t, strings.Join([]string{ + "-- MySQL dump 10.13", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://" + testEnvDomain + "');", + }, "\n")+"\n") +} + +// A dump Node rejects (DROP DATABASE) must not reach the database, and must +// not reach Docker at all. Before this, vip-next ran no SQL validation on the +// dev-env path whatsoever and imported it with exit 0. +func TestImportSQLBlocksDumpNodeRejects(t *testing.T) { + seedImportEnv(t) + body, err := os.ReadFile(validDump(t)) // #nosec G304 + if err != nil { + t.Fatal(err) + } + path := writeSQL(t, "DROP DATABASE wordpress;\n"+string(body)) + + f := &fakeImportRunner{} + importErr := importSQL(context.Background(), f, "e", path, ImportOptions{Out: io.Discard}) + if importErr == nil { + t.Fatal("a dump containing DROP DATABASE was imported") + } + if !strings.Contains(importErr.Error(), "DROP DATABASE statement on line(s) 1.") { + t.Errorf("unexpected error: %v", importErr) + } + if len(f.calls) != 0 || len(f.docker) != 0 || len(f.stdin) != 0 { + t.Errorf("validation must fail before anything is executed; ran compose=%v docker=%v", f.joined(), f.docker) + } +} + +// --skip-validate is the escape hatch and must genuinely skip the checks. +func TestImportSQLSkipValidateImportsRejectedDump(t *testing.T) { + seedImportEnv(t) + path := writeSQL(t, "DROP DATABASE wordpress;\nUSE other;\n") + + f := &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", path, ImportOptions{SkipValidate: true, Out: io.Discard}); err != nil { + t.Fatalf("--skip-validate must skip validation, got: %v", err) + } + if !strings.Contains(strings.Join(f.joined(), "\n"), "db import") { + t.Errorf("the import did not run: %v", f.joined()) + } +} + +// Node gates on isContainerRunning(php) && isContainerRunning(database) +// (dev-env-import-sql.ts:84-93) with this exact message. vip-next used to fail +// deep inside `docker compose ps -q php` with an opaque error instead. +func TestImportSQLRequiresRunningEnvironment(t *testing.T) { + for _, tc := range []struct { + name string + states []dockercli.ServiceState + }{ + {"php stopped", []dockercli.ServiceState{{Service: "php", State: "exited"}, {Service: "database", State: "running"}}}, + {"database stopped", []dockercli.ServiceState{{Service: "php", State: "running"}, {Service: "database", State: "exited"}}}, + {"nothing created", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + seedImportEnv(t) + f := &fakeImportRunner{psStates: tc.states, psSet: true} + err := importSQL(context.Background(), f, "e", validDump(t), ImportOptions{Out: io.Discard}) + if err == nil || err.Error() != "Environment needs to be started first" { + t.Fatalf("err = %v, want Node's exact UserError message", err) + } + if len(f.calls) != 0 || len(f.docker) != 0 { + t.Errorf("nothing may run against a stopped environment; ran %v %v", f.joined(), f.docker) + } + }) + } +} + +// One flag skips both, exactly as Node has it: the gate and the checks live +// inside the same `if ( ! this.options.skipValidate )`. +func TestImportSQLSkipValidateSkipsRunningEnvironmentGate(t *testing.T) { + seedImportEnv(t) + f := &fakeImportRunner{psStates: nil, psSet: true} + if err := importSQL(context.Background(), f, "e", validDump(t), ImportOptions{SkipValidate: true, Out: io.Discard}); err != nil { + t.Fatalf("--skip-validate must skip the running-environment gate too, got: %v", err) + } +} + +// Node validates `resolvedPath` — the file AFTER search-replace +// (dev-env-import-sql.ts:76-96) — so `--search-replace` is what makes a +// production dump importable. Validating the ORIGINAL would reject it. +func TestImportSQLValidatesTheSearchReplacedFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake search-replace binary is POSIX-only") + } + seedImportEnv(t) + bin := filepath.Join(t.TempDir(), "go-search-replace") + script := "#!/bin/sh\nsed 's|example\\.com|" + testEnvDomain + "|g'\n" + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + + path := writeSQL(t, strings.Join([]string{ + "-- MySQL dump 10.13", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://example.com');", + }, "\n")+"\n") + + // Without pairs the production URL survives and the import is blocked. + f := &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", path, ImportOptions{Out: io.Discard}); err == nil { + t.Fatal("a production siteurl with no --search-replace must be blocked") + } + + // With pairs the validated file is the rewritten one, so it imports. + seedImportEnv(t) + f = &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", path, ImportOptions{ + SearchReplace: []string{"example.com," + testEnvDomain}, + Out: io.Discard, + }); err != nil { + t.Fatalf("--search-replace must make the dump valid, got: %v", err) + } + if !strings.Contains(strings.Join(f.joined(), "\n"), "db import") { + t.Errorf("the import did not run: %v", f.joined()) + } +} + +// Deliberate ordering divergence: the running-environment gate runs BEFORE the +// search-replace, so a stopped environment cannot leave a half-rewritten dump +// (Node resolves the import path first and rewrites --in-place regardless). +func TestImportSQLGateRunsBeforeSearchReplace(t *testing.T) { + seedImportEnv(t) + // A binary that cannot exist: if search-replace ran, the error would be an + // exec failure rather than the gate message. + t.Setenv("VIP_SEARCH_REPLACE_BIN", filepath.Join(t.TempDir(), "definitely-not-here")) + + f := &fakeImportRunner{psStates: nil, psSet: true} + err := importSQL(context.Background(), f, "e", validDump(t), ImportOptions{ + SearchReplace: []string{"a,b"}, + Out: io.Discard, + }) + if err == nil || err.Error() != "Environment needs to be started first" { + t.Fatalf("err = %v, want the gate to fire before any file rewrite", err) + } +} diff --git a/internal/devenv/importvalidate.go b/internal/devenv/importvalidate.go new file mode 100644 index 000000000..0411a2ffe --- /dev/null +++ b/internal/devenv/importvalidate.go @@ -0,0 +1,437 @@ +package devenv + +import ( + "fmt" + "io" + "regexp" + "strconv" + "strings" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/sqlvalidation" +) + +// SQL validation for `vip dev-env import sql`. +// +// Node runs the same check suite the platform runs, with two differences +// (src/commands/dev-env-import-sql.ts:83-100): +// +// - skipChecks is `[]` for a mysqldump, which OVERRIDES +// DEFAULT_VALIDATION_OPTIONS.skipChecks (= DEV_ENV_SPECIFIC_CHECKS), so the +// dev-env path runs the two checks `vip import validate-sql` deliberately +// skips: useStatement and siteHomeUrlLando. +// - a MyDumper dump skips [ 'dropTable', 'dropDB' ]. +// +// It then hard-fails on every finding. vip-next does NOT: see devEnvSQLTiers. + +// tier is the severity vip-next assigns a dev-env SQL finding. +type tier int + +const ( + // tierFatal blocks the import: non-zero exit, nothing written to the DB. + tierFatal tier = iota + // tierWarning prints and continues: exit 0, the import runs. + tierWarning + // tierInfo prints only in the informational summary. + tierInfo +) + +// Synthetic tier keys for the two findings that are not checks of their own: +// sub-classifiers Node runs inside postValidation. +const ( + // tierKeyTablePrefix is the wp_ / wp_<n>_ / non-wp_ classification + // requiredCheckFormatter runs on createTable's results (sql.ts:182-186, + // only when isImport === false — which the dev-env path is). + tierKeyTablePrefix = "tablePrefix" + // tierKeyDuplicateTables is postValidation's duplicate-table-name scan + // (sql.ts:442-456). + tierKeyDuplicateTables = "duplicateTables" +) + +// devEnvSQLTiers is THE severity table for `vip dev-env import sql`. It is the +// single place severity is decided; nothing else in this package or in +// internal/sqlvalidation encodes "does this block?". +// +// DIVERGENCE FROM NODE (cutover register): Node hard-fails on all of these +// except siteHomeUrlLando. vip-next tiers them, because most of Node's checks +// encode VIP *Platform* policy that is meaningless on a local Docker container: +// refusing to import a dump into the user's own laptop because it uses MyISAM +// helps nobody. The rule applied below is: does the statement damage or +// misdirect the LOCAL environment (fatal), or is it a hosting-platform rule +// (warning)? +// +// FATAL — the import is stopped: +// +// dropDB DROP DATABASE destroys the environment's database. +// useStatement USE <db> points the import at a database that is not the +// environment's, so the data lands somewhere unexpected. +// siteHomeUrlLando siteurl/home still names another host: the LOCAL site +// would redirect to it (usually production). This is the +// highest-value check on this path, and the one place we are +// STRICTER than Node — Node marks its results `warning: true` +// (sql.ts:359) so problemsFound never increments and the +// import proceeds with a silently broken local site. +// alterUser ALTER USER / SET PASSWORD rewrites the container MySQL +// credentials that compose hardcodes, breaking the env's DB +// access. Same class as useStatement: it misconfigures the +// local server rather than violating a platform rule. +// (Not named in the tiering brief; classified here by the +// same rule, and called out in the handover for review.) +// createTable \ +// dropTable > Node's "required" checks (requiredCheckFormatter): +// autoIncrement / ABSENCE is the failure. They assert the dump is a whole +// dump and not a truncated or partial one — importing +// half a dump over a working DB is worse than not +// importing at all. +// duplicateTables The dump contradicts itself; the later CREATE wins and the +// earlier table's rows are lost. +// +// WARNING — printed, import proceeds: +// +// engineInnoDB MyISAM works fine in the local MariaDB container. +// alterTable "define the structure in CREATE TABLE instead" is a style rule. +// uniqueChecks SET UNIQUE_CHECKS=0 is a standard mysqldump speed optimization. +// binaryLogging Replication/binlog infrastructure concern; local has no replicas. +// trigger A VIP Platform restriction, not a MySQL one. +// tablePrefix `wp_` naming is VIP Platform policy; a local WP install runs +// happily with any prefix. +// +// INFO — summary only: +// +// siteHomeUrl Node's infoCheckFormatter: reports the siteurl/home values it +// saw. Never a finding. +var devEnvSQLTiers = map[string]tier{ + "dropDB": tierFatal, + sqlvalidation.CheckUseStatement: tierFatal, + sqlvalidation.CheckSiteHomeURLLando: tierFatal, + "alterUser": tierFatal, + "createTable": tierFatal, + "dropTable": tierFatal, + "autoIncrement": tierFatal, + tierKeyDuplicateTables: tierFatal, + + "engineInnoDB": tierWarning, + "alterTable": tierWarning, + "uniqueChecks": tierWarning, + "binaryLogging": tierWarning, + "trigger": tierWarning, + tierKeyTablePrefix: tierWarning, + + "siteHomeUrl": tierInfo, +} + +// tierOf returns the configured tier for a check key. An unlisted key is +// treated as fatal (fail closed) — TestDevEnvSQLTierTableCoversEveryRegisteredCheck +// makes sure that never actually happens. +func tierOf(key string) tier { + if t, ok := devEnvSQLTiers[key]; ok { + return t + } + return tierFatal +} + +// sqlValidationInput is everything the dev-env SQL validation needs. +type sqlValidationInput struct { + // Path is the file that will actually be imported — i.e. AFTER any + // file-level search-replace, matching Node's `resolvedPath`. + Path string + // ExpectedDomain is "<slug>.<domain>", the host the local site serves. + ExpectedDomain string + // IsMyDumper selects Node's skipChecks:['dropTable','dropDB'] branch. + IsMyDumper bool + // HasSearchReplace reports whether the user supplied --search-replace + // pairs. It only matters for a MyDumper dump — see below. + HasSearchReplace bool + // Quiet suppresses the informational report (header + ✅ summary). + // Warnings and the fatal block are never suppressed. + Quiet bool +} + +// devEnvValidationOptions builds the sqlvalidation options for a dev-env +// import. Ports dev-env-import-sql.ts:96-100. +func devEnvValidationOptions(expectedDomain string, skip []string) sqlvalidation.Options { + return sqlvalidation.Options{ + // Node passes skipChecks:[] for a mysqldump, which is what turns the + // two DEV_ENV_SPECIFIC_CHECKS back on for this path only. + SkipChecks: skip, + ExtraCheckParams: map[string]string{ + sqlvalidation.CheckSiteHomeURLLando: expectedDomain, + }, + } +} + +// skipChecksFor mirrors Node's `isMyDumper ? [ 'dropTable', 'dropDB' ] : []` +// plus one vip-next-only addition. +func skipChecksFor(in sqlValidationInput) []string { + if !in.IsMyDumper { + return nil + } + // Node: a MyDumper stream contains neither statement. + skip := []string{"dropTable", "dropDB"} + if in.HasSearchReplace { + // vip-next-only. Node rewrites a MyDumper dump on disk before + // validating it (resolveImportPath), so by validation time the URLs are + // already local. vip-next deliberately does NOT rewrite a MyDumper file + // — that invalidates the per-file byte markers `myloader --stream` + // depends on — and runs `wp search-replace` on the live DB after the + // import instead. The file therefore still carries the source domain at + // validation time, so keeping the check here would fail every correct + // MyDumper + --search-replace import. With no pairs supplied nothing + // will fix the domain, so the check stays on. + skip = append(skip, sqlvalidation.CheckSiteHomeURLLando) + } + return skip +} + +// validateDevEnvSQL runs the tiered validation and renders the report to out. +// It returns a non-nil error — carrying the full, already-formatted error block +// — when a FATAL finding was made; the caller must not import in that case. +func validateDevEnvSQL(in sqlValidationInput, out io.Writer) error { + res, err := sqlvalidation.ValidateFileWith(in.Path, devEnvValidationOptions(in.ExpectedDomain, skipChecksFor(in))) + if err != nil { + return err + } + + var fatals, warnings []finding + var infos []string + problems := 0 + + add := func(key string, found []finding) { + if len(found) == 0 { + return + } + switch tierOf(key) { + case tierFatal: + // Node's problemsFound counts CHECKS, not findings + // (sql.ts:122,180,188) — one offending check is one error. + problems++ + fatals = append(fatals, found...) + case tierWarning: + warnings = append(warnings, found...) + case tierInfo: + for _, f := range found { + infos = append(infos, f.Message) + } + } + } + + for _, check := range res.Checks { + found, checkInfos, prefixFindings := formatDevEnvCheck(check) + add(check.Key, found) + add(tierKeyTablePrefix, prefixFindings) + infos = append(infos, checkInfos...) + } + + // postValidation's duplicate-table scan (sql.ts:442-456). + if dups := duplicateTableNames(res.TableNames); len(dups) > 0 { + add(tierKeyDuplicateTables, []finding{{ + Message: "Duplicate table names were found: " + strings.Join(dups, ","), + Recommendation: "Ensure that there are no duplicate tables in your SQL dump", + }}) + } + + if !in.Quiet { + // Node sql.ts:412-415 — the dev-env path calls validate() with + // isImport:false, so the line counter and the ✅ summary both print. + fmt.Fprintf(out, "Finished processing %d lines.\n\n", res.LinesProcessed) + } + + if len(warnings) > 0 { + // Yellow "Warning:" against the fatal block's red "SQL Error:", plus an + // explicit statement that nothing was blocked. + fmt.Fprintln(out, strings.Join(renderFindings(warningLabel, warnings), "\n")) + fmt.Fprintln(out, color.YellowString( + "%s did not block the import: they are VIP Platform rules that do not apply to a local environment.", + pluralize(len(warnings), "warning above", "warnings above"))) + fmt.Fprintln(out) + } + + if problems > 0 { + return fmt.Errorf("%s\n%s\n\nIf you are confident that the file does not contain unsupported statements, you can retry the command with the %s option.", + strings.Join(renderFindings(errorLabel, fatals), "\n"), + color.New(color.FgRed, color.Bold).Sprint( + "SQL validation failed due to "+strconv.Itoa(problems)+" error(s)"), + color.YellowString("--skip-validate")) + } + + if !in.Quiet && len(infos) > 0 { + fmt.Fprintln(out, strings.Join(infos, "\n")) + fmt.Fprintln(out) + } + return nil +} + +// finding is one rendered problem, kept label-free so the SAME structure can be +// printed as a fatal ("SQL Error:") or as a warning ("Warning:") depending on +// its tier. The label is applied once, at print time, by renderFindings. +type finding struct { + Message string + Recommendation string +} + +// formatDevEnvCheck renders one check into (findings, infos, prefixFindings). +// findings/prefixFindings are attributed to a tier by the caller; infos always +// go to the informational summary. Mirrors the four Node formatters +// (sql.ts:116-215) minus their problemsFound bookkeeping, which the tier table +// replaces. +func formatDevEnvCheck(c *sqlvalidation.Check) (findings []finding, infos []string, prefixFindings []finding) { + switch c.Formatter { + case sqlvalidation.FormatterLineNumber: + // lineNumberCheckFormatter (sql.ts:150). + if len(c.Results) == 0 { + return nil, []string{"✅ " + c.Message + " was found 0 times."}, nil + } + lines := make([]string, len(c.Results)) + for i, r := range c.Results { + lines[i] = strconv.Itoa(r.Line) + } + return []finding{{ + Message: c.Message + " on line(s) " + strings.Join(lines, ", ") + ".", + Recommendation: c.Recommendation, + }}, nil, nil + + case sqlvalidation.FormatterRequired: + // requiredCheckFormatter (sql.ts:171) — inverted: absence is the problem. + if len(c.Results) == 0 { + return []finding{{ + Message: c.Message + " was not found.", + Recommendation: c.Recommendation, + }}, nil, nil + } + infos = []string{fmt.Sprintf("✅ %s was found %d times.", c.Message, len(c.Results))} + if c.Key == "createTable" { + // Node runs checkTablePrefixes only when isImport === false, and + // the dev-env path passes isImport:false (dev-env-import-sql.ts:97). + prefixErrs, prefixInfos := checkDevEnvTablePrefixes(c.Results) + infos = append(infos, prefixInfos...) + prefixFindings = prefixErrs + } + return nil, infos, prefixFindings + + case sqlvalidation.FormatterInfo: + // infoCheckFormatter (sql.ts:202). + for _, r := range c.Results { + if r.Text != "" { + infos = append(infos, r.Text) + } + } + return nil, infos, nil + + case sqlvalidation.FormatterGeneral: + // generalCheckFormatter (sql.ts:116) — drops falsePositives, then one + // line per surviving result with that result's own recommendation. + var valid []sqlvalidation.CheckResult + for _, r := range c.Results { + if !r.FalsePositive { + valid = append(valid, r) + } + } + if len(valid) == 0 { + return nil, []string{"✅ " + c.Message + " was found 0 times."}, nil + } + for _, r := range valid { + rec := r.Recommendation + if rec == "" { + rec = c.Recommendation + } + findings = append(findings, finding{ + // Node's generalCheckFormatter says "on line N." (singular), + // unlike lineNumberCheckFormatter's "on line(s) …". + Message: fmt.Sprintf("%s on line %d.", c.Message, r.Line), + Recommendation: rec, + }) + } + return findings, nil, nil + } + return nil, nil, nil +} + +// wpMultisitePrefix mirrors Node sql.ts:223's /^wp_(\d+_)/. +var wpMultisitePrefix = regexp.MustCompile(`^wp_\d+_`) + +// checkDevEnvTablePrefixes ports checkTablePrefixes (sql.ts:217). +func checkDevEnvTablePrefixes(results []sqlvalidation.CheckResult) (findings []finding, infos []string) { + var wpTables, notWPTables, multisiteTables []string + for _, r := range results { + switch { + case wpMultisitePrefix.MatchString(r.Text): + multisiteTables = append(multisiteTables, r.Text) + case strings.HasPrefix(r.Text, "wp_"): + wpTables = append(wpTables, r.Text) + default: + notWPTables = append(notWPTables, r.Text) + } + } + if len(wpTables) > 0 { + infos = append(infos, fmt.Sprintf(" - wp_ prefix tables found: %d ", len(wpTables))) + } + if len(notWPTables) > 0 { + findings = append(findings, finding{ + Message: "tables without wp_ prefix found: " + strings.Join(notWPTables, ","), + Recommendation: "Please make sure all table names are prefixed with `wp_`", + }) + } + if len(multisiteTables) > 0 { + infos = append(infos, fmt.Sprintf(" - wp_n_ prefix tables found: %d ", len(multisiteTables))) + } + return findings, infos +} + +// duplicateTableNames returns the names that appear more than once, in first +// appearance order. Ports findDuplicates (sql.ts:396). +func duplicateTableNames(names []string) []string { + counts := map[string]int{} + for _, n := range names { + counts[n]++ + } + var out []string + emitted := map[string]bool{} + for _, n := range names { + if counts[n] > 1 && !emitted[n] { + out = append(out, n) + emitted[n] = true + } + } + return out +} + +// errorLabel / warningLabel / formatRecommendation mirror the chalk wrappers in +// sql.ts:19-29. The two tiers are visually distinguished purely by the label a +// finding is rendered with: red "SQL Error:" vs yellow "Warning:". +func errorLabel(msg string) string { return color.RedString("SQL Error:") + " " + msg } +func warningLabel(msg string) string { return color.YellowString("Warning:") + " " + msg } + +func formatRecommendation(m string) string { return color.YellowString("Recommendation:") + " " + m } + +// renderFindings expands findings into Node's three-line-per-problem layout: +// the labelled message, the recommendation, and a blank separator +// (sql.ts:460-468 for warnings, :480-488 for errors). +func renderFindings(label func(string) string, found []finding) []string { + out := make([]string, 0, len(found)*3) + for _, f := range found { + out = append(out, label(f.Message), formatRecommendation(f.Recommendation), "") + } + return out +} + +func pluralize(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return strconv.Itoa(n) + " " + many +} + +// devEnvDomain resolves the domain the environment actually serves, using the +// same resolution as the rest of the dev-env code (see runDevEnvSyncSQL): +// instance data pins it for new envs, and instancedata.Read backfills a legacy +// env's empty value to LegacyDomain. A missing/unreadable instance file falls +// back to the default so validation still has something to compare against. +func devEnvDomain(slug string) string { + if d, err := instancedata.Read(slug); err == nil && d.Domain != "" { + return d.Domain + } + return compose.DefaultDomain +} diff --git a/internal/devenv/importvalidate_test.go b/internal/devenv/importvalidate_test.go new file mode 100644 index 000000000..261838709 --- /dev/null +++ b/internal/devenv/importvalidate_test.go @@ -0,0 +1,293 @@ +package devenv + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/sqlvalidation" +) + +// cleanDump is a minimal dump that passes every FATAL dev-env check: it has +// DROP TABLE + CREATE TABLE + AUTO_INCREMENT (the three "required" checks, +// where ABSENCE is the failure), an InnoDB engine, no USE/DROP DATABASE, and +// a siteurl already pointing at the local environment. +func cleanDump(t *testing.T, domain string) string { + t.Helper() + return writeSQL(t, strings.Join([]string{ + "-- MySQL dump 10.13", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://" + domain + "');", + }, "\n")+"\n") +} + +func writeSQL(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "dump.sql") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// The tier table must be exhaustive. A check registered by the dev-env option +// set with no entry would silently get whatever the zero value happens to be — +// exactly the "severity scattered around" failure the table exists to prevent. +func TestDevEnvSQLTierTableCoversEveryRegisteredCheck(t *testing.T) { + res, err := sqlvalidation.ValidateWith(strings.NewReader(""), devEnvValidationOptions("e.vipdev.site", nil), nil) + if err != nil { + t.Fatal(err) + } + for _, c := range res.Checks { + if _, ok := devEnvSQLTiers[c.Key]; !ok { + t.Errorf("check %q has no entry in devEnvSQLTiers", c.Key) + } + } + // And no stale entries for checks that no longer exist. + known := map[string]bool{tierKeyTablePrefix: true, tierKeyDuplicateTables: true} + for _, c := range res.Checks { + known[c.Key] = true + } + for key := range devEnvSQLTiers { + if !known[key] { + t.Errorf("devEnvSQLTiers has a stale entry %q", key) + } + } +} + +func TestDevEnvSQLValidationAcceptsCleanDump(t *testing.T) { + var out bytes.Buffer + err := validateDevEnvSQL(sqlValidationInput{ + Path: cleanDump(t, "e.vipdev.site"), + ExpectedDomain: "e.vipdev.site", + }, &out) + if err != nil { + t.Fatalf("clean dump rejected: %v\noutput:\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "Finished processing 7 lines.") { + t.Errorf("missing Node's line-count header, got:\n%s", out.String()) + } +} + +// The FATAL tier: each of these must block the import. +func TestDevEnvSQLValidationFatalChecks(t *testing.T) { + cases := []struct { + name string + sql string + want string + }{ + {"dropDB", "DROP DATABASE wordpress;", "DROP DATABASE statement on line(s) 1."}, + {"useStatement", "USE some_other_db;", "USE <DATABASE_NAME> statement on line(s) 1."}, + {"alterUser", "ALTER USER 'wordpress'@'%' IDENTIFIED BY 'x';", "ALTER USER statement on line(s) 1."}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Append the offending line to an otherwise-clean dump so the + // required checks are satisfied and only this one fires. + body, err := os.ReadFile(cleanDump(t, "e.vipdev.site")) // #nosec G304 + if err != nil { + t.Fatal(err) + } + path := writeSQL(t, tc.sql+"\n"+string(body)) + + var out bytes.Buffer + verr := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out) + if verr == nil { + t.Fatalf("%s did not block the import; output:\n%s", tc.name, out.String()) + } + if !strings.Contains(verr.Error(), tc.want) { + t.Errorf("error =\n%s\nwant it to contain %q", verr.Error(), tc.want) + } + if !strings.Contains(verr.Error(), "SQL validation failed due to 1 error(s)") { + t.Errorf("missing Node's footer, got:\n%s", verr.Error()) + } + }) + } +} + +// The "required" checks invert: absence is the failure. A truncated dump that +// contains none of them must not be imported. +func TestDevEnvSQLValidationRequiredChecksBlockWhenAbsent(t *testing.T) { + path := writeSQL(t, "-- MySQL dump 10.13\nINSERT INTO `wp_options` VALUES (1,'a','b');\n") + var out bytes.Buffer + err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out) + if err == nil { + t.Fatalf("a dump with no DROP TABLE / CREATE TABLE / AUTO_INCREMENT was accepted; output:\n%s", out.String()) + } + for _, want := range []string{ + "DROP TABLE was not found.", + "CREATE TABLE was not found.", + "AUTO_INCREMENT attribute was not found.", + "SQL validation failed due to 3 error(s)", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q; got:\n%s", want, err.Error()) + } + } +} + +// The single most valuable dev-env check: a production dump imported without a +// search-replace leaves the LOCAL site pointing at production. +func TestDevEnvSQLValidationSiteHomeURLLandoIsFatalWithFlagAdvice(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://example.com');", + }, "\n")+"\n") + + var out bytes.Buffer + err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out) + if err == nil { + t.Fatalf("a production siteurl was accepted; output:\n%s", out.String()) + } + for _, want := range []string{ + "Siteurl/home options not pointing to lando domain on line 3.", + `Use '--search-replace="example.com,e.vipdev.site"' switch to replace the domain`, + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q; got:\n%s", want, err.Error()) + } + } +} + +// The WARNING tier: printed, but the import proceeds and the output says so. +func TestDevEnvSQLValidationWarningsDoNotBlock(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "SET UNIQUE_CHECKS = 0;", + "SET @@SESSION.sql_log_bin = 0;", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=MyISAM;", + "ALTER TABLE `wp_options` ADD COLUMN x INT;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://e.vipdev.site');", + }, "\n")+"\n") + + var out bytes.Buffer + if err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out); err != nil { + t.Fatalf("warnings must not block the import, got: %v", err) + } + got := out.String() + for _, want := range []string{ + "Warning: SET UNIQUE_CHECKS = 0 on line(s) 1.", + "Warning: SET @@SESSION.sql_log_bin statement on line(s) 2.", + "Warning: ENGINE != InnoDB on line(s) 4.", + "Warning: ALTER TABLE statement on line(s) 5.", + "did not block the import", + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q; got:\n%s", want, got) + } + } + if strings.Contains(got, "SQL Error:") { + t.Errorf("warning-only run emitted fatal formatting:\n%s", got) + } +} + +// Node skips dropTable and dropDB for a MyDumper dump (dev-env-import-sql.ts:98) +// because a MyDumper stream carries neither. +func TestDevEnvSQLValidationMyDumperSkipsDropChecks(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "DROP DATABASE wordpress;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://e.vipdev.site');", + }, "\n")+"\n") + + var out bytes.Buffer + if err := validateDevEnvSQL(sqlValidationInput{ + Path: path, ExpectedDomain: "e.vipdev.site", IsMyDumper: true, + }, &out); err != nil { + t.Fatalf("MyDumper must skip dropTable/dropDB, got: %v", err) + } +} + +// vip-next does NOT file-level search-replace a MyDumper dump (that would +// invalidate the per-file byte markers myloader's --stream relies on); it runs +// `wp search-replace` on the live DB AFTER the import instead. So a MyDumper +// dump's siteurl still names the source domain at validation time even when the +// user did supply --search-replace, and flagging it would be a guaranteed false +// positive on the normal workflow. +func TestDevEnvSQLValidationMyDumperWithSearchReplaceSkipsSiteHomeURLLando(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + "INSERT INTO `wp_options` VALUES (1,'siteurl','https://example.com');", + }, "\n")+"\n") + + var out bytes.Buffer + if err := validateDevEnvSQL(sqlValidationInput{ + Path: path, ExpectedDomain: "e.vipdev.site", IsMyDumper: true, HasSearchReplace: true, + }, &out); err != nil { + t.Fatalf("MyDumper + --search-replace must not trip siteHomeUrlLando, got: %v", err) + } + + // …but with NO search-replace pairs nothing will fix the domain, so the + // check is meaningful and must still fire. + out.Reset() + if err := validateDevEnvSQL(sqlValidationInput{ + Path: path, ExpectedDomain: "e.vipdev.site", IsMyDumper: true, + }, &out); err == nil { + t.Error("MyDumper with no search-replace must still flag a foreign siteurl") + } +} + +// The wp_-prefix classifier is VIP naming policy, not a local hazard: it warns. +func TestDevEnvSQLValidationTablePrefixWarnsOnly(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "DROP TABLE IF EXISTS `custom_table`;", + "CREATE TABLE `custom_table` (`id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=InnoDB;", + }, "\n")+"\n") + + var out bytes.Buffer + if err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out); err != nil { + t.Fatalf("a non-wp_ table name must not block a LOCAL import, got: %v", err) + } + if !strings.Contains(out.String(), "tables without wp_ prefix found: custom_table") { + t.Errorf("expected a prefix warning, got:\n%s", out.String()) + } +} + +// Duplicate table names mean the dump itself is broken — the second CREATE +// wins and the first table's data is lost. +func TestDevEnvSQLValidationDuplicateTablesAreFatal(t *testing.T) { + path := writeSQL(t, strings.Join([]string{ + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;", + }, "\n")+"\n") + + var out bytes.Buffer + err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site"}, &out) + if err == nil { + t.Fatal("a dump with duplicate table names was accepted") + } + if !strings.Contains(err.Error(), "Duplicate table names were found: wp_options") { + t.Errorf("error =\n%s", err.Error()) + } +} + +// --quiet suppresses the informational report but never the warnings, and +// never the fatal block. +func TestDevEnvSQLValidationQuietStillReportsProblems(t *testing.T) { + var out bytes.Buffer + if err := validateDevEnvSQL(sqlValidationInput{ + Path: cleanDump(t, "e.vipdev.site"), ExpectedDomain: "e.vipdev.site", Quiet: true, + }, &out); err != nil { + t.Fatal(err) + } + if out.Len() != 0 { + t.Errorf("--quiet must print nothing for a clean dump, got:\n%s", out.String()) + } + + out.Reset() + path := writeSQL(t, "DROP TABLE IF EXISTS `wp_options`;\nCREATE TABLE `wp_options` (`id` bigint NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)) ENGINE=MyISAM;\n") + if err := validateDevEnvSQL(sqlValidationInput{Path: path, ExpectedDomain: "e.vipdev.site", Quiet: true}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Warning: ENGINE != InnoDB") { + t.Errorf("--quiet must still surface warnings, got:\n%s", out.String()) + } +} diff --git a/internal/devenv/info_table.go b/internal/devenv/info_table.go new file mode 100644 index 000000000..dbf0bca9f --- /dev/null +++ b/internal/devenv/info_table.go @@ -0,0 +1,165 @@ +package devenv + +import ( + "fmt" + "sort" + "strings" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/lifecycle" + "github.com/Automattic/vip/internal/devenv/paths" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +// documentationURL is the docs link Node prints in the info table +// (dev-environment-lando.ts:703). +const documentationURL = "https://docs.wpvip.com/vip-local-development-environment/" + +// serviceOrder is the canonical SERVICES ordering Node displays; services not +// listed here are appended alphabetically. +var serviceOrder = []string{ + "nginx", "php", "database", "memcached", "wordpress", + "vip-mu-plugins", "demo-app-code", "elasticsearch", "phpmyadmin", "mailpit", "photon", +} + +// renderEnvInfo builds the full Node-parity info table for an environment. It is +// pure given the resolved view + the proxy ports + the container states, so the +// docker-touching gather lives in Info() and the formatting stays unit-testable. +func renderEnvInfo(slug string, view compose.View, ports proxy.Ports, states []lifecycle.ServiceState) string { + return renderInfoTable(buildInfoRows(slug, view, ports, states)) +} + +func buildInfoRows(slug string, view compose.View, ports proxy.Ports, states []lifecycle.ServiceState) []infoRow { + host := view.SiteSlug + "." + view.Domain + httpURL := frontURL("http", host, ports.HTTP, proxy.DefaultHTTP) + httpsURL := frontURL("https", host, ports.HTTPS, proxy.DefaultHTTPS) + + rows := []infoRow{ + {label: "SLUG", values: []string{slug}}, + {label: "LOCATION", values: []string{paths.EnvironmentPath(slug)}}, + {label: "SERVICES", values: []string{strings.Join(servicesList(view), ", ")}}, + {label: "NGINX URLS", values: []string{httpURL, httpsURL}}, + {label: "STATUS", values: []string{statusFromStates(states)}}, + } + + // Login + credentials. Node prints these whenever the front-end URL is + // configured (always), regardless of running state. The autologin query is + // appended only when a key is present. NOTE: unlike Node — which reuses the + // HTTP port for the HTTPS login URL (a Lando quirk) — we use the correct + // per-scheme port so both login URLs actually load. + loginQuery := "" + if view.AutologinKey != "" { + loginQuery = "?vip-dev-autologin=" + view.AutologinKey + } + httpsLogin := httpsURL + "wp-admin/" + loginQuery + httpLogin := httpURL + "wp-admin/" + loginQuery + rows = append(rows, + infoRow{label: "LOGIN URL", values: []string{httpsLogin, httpLogin}}, + infoRow{label: "DEFAULT USERNAME", values: []string{"vipgo"}}, + infoRow{label: "DEFAULT PASSWORD", values: []string{view.AdminPassword}}, + infoRow{label: "DOCUMENTATION", values: []string{documentationURL}}, + ) + if view.MigratedFromLando != "" { + rows = append(rows, infoRow{label: "MIGRATED FROM LANDO", values: []string{view.MigratedFromLando}}) + } + return rows +} + +// frontURL renders "scheme://host[:port]/", omitting the port when it is the +// scheme's default. +func frontURL(scheme, host string, port, defaultPort int) string { + if port == 0 || port == defaultPort { + return fmt.Sprintf("%s://%s/", scheme, host) + } + return fmt.Sprintf("%s://%s:%d/", scheme, host, port) +} + +// servicesList returns the env's configured service names in canonical order +// (derived from the compose project, so it reflects enabled conditional +// services and does not depend on anything running). +func servicesList(v compose.View) []string { + present := map[string]bool{} + for name := range compose.BuildProject(v).Services { + present[name] = true + } + var out []string + for _, n := range serviceOrder { + if present[n] { + out = append(out, n) + delete(present, n) + } + } + leftover := make([]string, 0, len(present)) + for n := range present { + leftover = append(leftover, n) + } + sort.Strings(leftover) + return append(out, leftover...) +} + +// statusFromStates derives the env status from container states: UP when nginx +// is running, PARTIALLY UP when some other service is running, else DOWN. +func statusFromStates(states []lifecycle.ServiceState) string { + nginxUp, anyUp := false, false + for _, s := range states { + if s.State == "running" { + anyUp = true + if s.Service == "nginx" { + nginxUp = true + } + } + } + switch { + case nginxUp: + return "UP" + case anyUp: + return "PARTIALLY UP" + default: + return "DOWN" + } +} + +// infoRow is one label/value(s) row of the dev-env info table. A row with no +// values is omitted (e.g. LOGIN URL when the env is not running). +type infoRow struct { + label string + values []string +} + +// renderInfoTable renders the borderless, padded two-column table Node prints +// after create/start and for `dev-env info` (ports getLandoFormatters table: +// dev-environment-cli.ts printTable). Each row is " <LABEL> <value>" with the +// label column padded to the longest label; multi-line values align under the +// first value. +func renderInfoTable(rows []infoRow) string { + labelWidth := 0 + for _, r := range rows { + if len(r.values) == 0 { + continue + } + if len(r.label) > labelWidth { + labelWidth = len(r.label) + } + } + // 1 leading space + label column + 2 trailing spaces before the value. + indent := strings.Repeat(" ", 1+labelWidth+2) + + var b strings.Builder + for _, r := range rows { + if len(r.values) == 0 { + continue + } + b.WriteByte(' ') + b.WriteString(r.label) + b.WriteString(strings.Repeat(" ", labelWidth-len(r.label))) + b.WriteString(" ") + b.WriteString(r.values[0]) + b.WriteByte('\n') + for _, v := range r.values[1:] { + b.WriteString(indent) + b.WriteString(v) + b.WriteByte('\n') + } + } + return b.String() +} diff --git a/internal/devenv/info_table_test.go b/internal/devenv/info_table_test.go new file mode 100644 index 000000000..3a5c30caf --- /dev/null +++ b/internal/devenv/info_table_test.go @@ -0,0 +1,111 @@ +package devenv + +import ( + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/lifecycle" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +func TestBuildInfoRowsShowsLandoMigrationMarker(t *testing.T) { + v := compose.View{SiteSlug: "foo", Domain: "vipdev.site", AdminPassword: "password", MigratedFromLando: "2026-07-10T00:00:00Z"} + rows := buildInfoRows("foo", v, proxy.Ports{}, nil) + found := false + for _, r := range rows { + if r.label == "MIGRATED FROM LANDO" && len(r.values) == 1 && r.values[0] == "2026-07-10T00:00:00Z" { + found = true + } + } + if !found { + t.Fatalf("expected a MIGRATED FROM LANDO row, got %+v", rows) + } +} + +func TestBuildInfoRowsOmitsMarkerWhenEmpty(t *testing.T) { + v := compose.View{SiteSlug: "foo", Domain: "vipdev.site", AdminPassword: "password"} + for _, r := range buildInfoRows("foo", v, proxy.Ports{}, nil) { + if r.label == "MIGRATED FROM LANDO" { + t.Fatal("marker row must be omitted when unset") + } + } +} + +func TestRenderInfoTablePadsToLongestLabel(t *testing.T) { + rows := []infoRow{ + {label: "SLUG", values: []string{"demo"}}, + {label: "NGINX URLS", values: []string{"http://a/", "https://a/"}}, + {label: "DEFAULT USERNAME", values: []string{"vipgo"}}, + } + got := renderInfoTable(rows) + want := " SLUG demo\n" + + " NGINX URLS http://a/\n" + + " https://a/\n" + + " DEFAULT USERNAME vipgo\n" + if got != want { + t.Fatalf("renderInfoTable mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderEnvInfoRunningEnv(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + view := compose.View{SiteSlug: "demo", Domain: "vipdev.lndo.site", AutologinKey: "KEY-123", AdminPassword: "pw_secret12"} + ports := proxy.Ports{HTTP: 8000, HTTPS: 444} + states := []lifecycle.ServiceState{{Service: "nginx", State: "running"}, {Service: "php", State: "running"}} + + got := renderEnvInfo("demo", view, ports, states) + + wants := []string{ + " SLUG", + "demo", + "SERVICES", + "nginx, php, database, memcached, wordpress, vip-mu-plugins, demo-app-code", + "http://demo.vipdev.lndo.site:8000/", + "https://demo.vipdev.lndo.site:444/", + " STATUS", + "UP", + "https://demo.vipdev.lndo.site:444/wp-admin/?vip-dev-autologin=KEY-123", + "http://demo.vipdev.lndo.site:8000/wp-admin/?vip-dev-autologin=KEY-123", + "DEFAULT USERNAME", + "vipgo", + "DEFAULT PASSWORD", + "pw_secret12", + "https://docs.wpvip.com/vip-local-development-environment/", + } + for _, w := range wants { + if !strings.Contains(got, w) { + t.Fatalf("info table missing %q:\n%s", w, got) + } + } +} + +func TestRenderEnvInfoStoppedEnvShowsDown(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + view := compose.View{SiteSlug: "demo", Domain: "vipdev.lndo.site", AutologinKey: "K", AdminPassword: "password"} + got := renderEnvInfo("demo", view, proxy.Ports{HTTP: 80, HTTPS: 443}, nil) + + if !strings.Contains(got, "DOWN") { + t.Fatalf("expected STATUS DOWN for stopped env:\n%s", got) + } + // Default ports are omitted from the URL (no :80 / :443). + if !strings.Contains(got, "http://demo.vipdev.lndo.site/") { + t.Fatalf("expected default-port URL without :80:\n%s", got) + } + // Login/credential rows are shown even when down (Node parity). + if !strings.Contains(got, "DEFAULT USERNAME") { + t.Fatalf("expected login rows present when down:\n%s", got) + } +} + +func TestRenderInfoTableSkipsRowsWithNoValues(t *testing.T) { + rows := []infoRow{ + {label: "SLUG", values: []string{"demo"}}, + {label: "LOGIN URL", values: nil}, // env not running: omit + } + got := renderInfoTable(rows) + want := " SLUG demo\n" + if got != want { + t.Fatalf("expected empty-value row omitted:\n%q", got) + } +} diff --git a/internal/devenv/instancedata/instancedata.go b/internal/devenv/instancedata/instancedata.go new file mode 100644 index 000000000..ae34adff7 --- /dev/null +++ b/internal/devenv/instancedata/instancedata.go @@ -0,0 +1,239 @@ +// Package instancedata reads and writes a dev environment's +// instance_data.json. Ports the data layer of dev-environment-core.ts +// (readEnvironmentData / writeEnvironmentData / getAllEnvironmentNames / +// doesEnvironmentExist). Unknown keys are preserved losslessly so files +// written by older/newer CLIs survive a round-trip (spec §10). +package instancedata + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// ComponentConfig mirrors types.ts ComponentConfig. +type ComponentConfig struct { + Mode string `json:"mode"` + Dir string `json:"dir,omitempty"` + Image string `json:"image,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// WordPressConfig mirrors types.ts WordPressConfig. +type WordPressConfig struct { + Mode string `json:"mode"` + Tag string `json:"tag"` + Ref string `json:"ref,omitempty"` + DoNotUpgrade bool `json:"doNotUpgrade,omitempty"` +} + +// InstanceData mirrors types.ts InstanceData. multisite and elasticsearch +// are JS union types (bool|string), kept as json.RawMessage to round-trip +// faithfully; typed accessors are added in a later plan when consumers +// need them. Extra holds every key not modeled above, preserved verbatim. +type InstanceData struct { + SiteSlug string `json:"siteSlug"` + WPTitle string `json:"wpTitle"` + // Multisite is a JS union (bool|string). NOTE for environment-creation + // code (Plan 4): a nil value serializes as `"multisite": null`, which + // differs from the Node CLI (it always writes `false` or a string). + // Creators MUST set this explicitly (e.g. json.RawMessage("false")) to + // match Node output; reads round-trip whatever was on disk. + Multisite json.RawMessage `json:"multisite"` + WordPress WordPressConfig `json:"wordpress"` + MuPlugins ComponentConfig `json:"muPlugins"` + AppCode ComponentConfig `json:"appCode"` + MediaRedirectDomain string `json:"mediaRedirectDomain"` + PHPMyAdmin bool `json:"phpmyadmin"` + Xdebug bool `json:"xdebug"` + XdebugConfig string `json:"xdebugConfig,omitempty"` + MariaDB string `json:"mariadb,omitempty"` + PHP string `json:"php"` + Elasticsearch json.RawMessage `json:"elasticsearch,omitempty"` + Mailpit bool `json:"mailpit"` + Photon bool `json:"photon"` + Cron bool `json:"cron"` + PullAfter *int64 `json:"pullAfter,omitempty"` + AutologinKey string `json:"autologinKey,omitempty"` + AdminPassword string `json:"adminPassword,omitempty"` + Version string `json:"version,omitempty"` + Overrides string `json:"overrides,omitempty"` + // MigratedFromLando is an RFC3339 timestamp stamped the first time this env + // was adopted from a pre-existing Lando environment (Go-only; surfaced in + // `dev-env info`). Empty means never adopted. + MigratedFromLando string `json:"migratedFromLando,omitempty"` + // Domain is the per-env domain. New envs pin it explicitly at create + // (compose.DefaultDomain, "vipdev.site", unless `create --domain` overrides); + // an empty value marks a pre-switch/legacy env and is backfilled to + // LegacyDomain ("vipdev.lndo.site") on read. Consumed by compose.Options.Domain. + Domain string `json:"domain,omitempty"` + // ExternalVolumes maps a logical volume name to an existing (Lando) volume + // name; non-empty marks the env as migrated (Plan 4 §D). Declared external + // in the rendered compose so a destroy never deletes the original data. + ExternalVolumes map[string]string `json:"externalVolumes,omitempty"` + // EnvVars holds per-env user variables (Plan 5 `dev-env envvar`). Node + // stores these in the env's .env file; the Go port keeps them here in + // instance_data.json because Materialize owns/overwrites .env on every + // Start/Rebuild. They are injected into the php service environment on + // materialize (compose.View.EnvVars). + EnvVars map[string]string `json:"envVars,omitempty"` + + // Extra carries unmodeled keys verbatim for lossless round-trip. + Extra map[string]json.RawMessage `json:"-"` +} + +// LegacyDomain is the domain used by envs created before the vipdev.site switch +// (and Lando-migrated envs). An env whose stored Domain is empty predates the +// switch, so it is backfilled to this value on read — keeping its DB siteurl +// (which references *.vipdev.lndo.site) valid. New envs pin compose.DefaultDomain +// explicitly at create time, so they are never empty and never backfilled. +const LegacyDomain = "vipdev.lndo.site" + +// knownKeys is the set of JSON keys modeled by InstanceData's fields. +var knownKeys = map[string]bool{ + "siteSlug": true, "wpTitle": true, "multisite": true, "wordpress": true, + "muPlugins": true, "appCode": true, "mediaRedirectDomain": true, + "phpmyadmin": true, "xdebug": true, "xdebugConfig": true, "mariadb": true, + "php": true, "elasticsearch": true, "mailpit": true, "photon": true, + "cron": true, "pullAfter": true, "autologinKey": true, "adminPassword": true, + "version": true, "overrides": true, + "domain": true, "externalVolumes": true, "envVars": true, + "migratedFromLando": true, +} + +func parse(b []byte) (*InstanceData, error) { + d := &InstanceData{} + if err := json.Unmarshal(b, d); err != nil { + return nil, err + } + + var all map[string]json.RawMessage + if err := json.Unmarshal(b, &all); err != nil { + return nil, err + } + d.Extra = map[string]json.RawMessage{} + for k, v := range all { + if !knownKeys[k] { + d.Extra[k] = v + } + } + + applyBackcompat(d) + return d, nil +} + +// serialize merges modeled fields over the preserved unknown keys and +// emits 2-space-indented JSON (matching Node's JSON.stringify(data, null, 2) +// indentation; key ordering may differ, which is acceptable per spec §10). +func serialize(d *InstanceData) ([]byte, error) { + knownBytes, err := json.Marshal(d) + if err != nil { + return nil, err + } + var known map[string]json.RawMessage + if err := json.Unmarshal(knownBytes, &known); err != nil { + return nil, err + } + + merged := make(map[string]json.RawMessage, len(d.Extra)+len(known)) + for k, v := range d.Extra { + merged[k] = v + } + for k, v := range known { + merged[k] = v + } + return json.MarshalIndent(merged, "", " ") +} + +// applyBackcompat ports the BACKWARDS COMPATIBILITY section of +// readEnvironmentData (dev-environment-core.ts:558-575). +func applyBackcompat(d *InstanceData) { + // enterpriseSearchEnabled / elasticsearchEnabled -> elasticsearch + for _, legacy := range []string{"enterpriseSearchEnabled", "elasticsearchEnabled"} { + if v, ok := d.Extra[legacy]; ok && isTruthyJSON(v) { + d.Elasticsearch = json.RawMessage("true") + } + } + // clientCode -> appCode + if v, ok := d.Extra["clientCode"]; ok { + var cc ComponentConfig + if err := json.Unmarshal(v, &cc); err == nil { + d.AppCode = cc + } + } + // Envs created before the vipdev.site switch stored no domain; pin them to the + // legacy domain so they keep resolving to their original *.vipdev.lndo.site host. + if d.Domain == "" { + d.Domain = LegacyDomain + } +} + +func isTruthyJSON(v json.RawMessage) bool { + var b bool + if err := json.Unmarshal(v, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s != "" + } + return false +} + +const instanceDataFileName = "instance_data.json" + +// Read loads and migrates an environment's instance data. Error messages +// mirror readEnvironmentData (dev-environment-core.ts:529-578). +func Read(slug string) (*InstanceData, error) { + target := filepath.Join(paths.EnvironmentPath(slug), instanceDataFileName) + b, err := os.ReadFile(target) + if err != nil { + return nil, fmt.Errorf("There was an error reading file %q: %s.", target, err) + } + d, err := parse(b) + if err != nil { + return nil, fmt.Errorf("There was an error parsing file %q: %s. You may need to recreate the environment.", target, err) + } + return d, nil +} + +// Write serializes instance data to disk, creating the env directory if +// needed. Ports writeEnvironmentData (2-space indent). +func Write(slug string, d *InstanceData) error { + dir := paths.EnvironmentPath(slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + out, err := serialize(d) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, instanceDataFileName), out, 0o644) +} + +// Exists reports whether an environment's instance_data.json is a file. +// Ports doesEnvironmentExist (dev-environment-core.ts:518-527). +func Exists(slug string) bool { + info, err := os.Stat(filepath.Join(paths.EnvironmentPath(slug), instanceDataFileName)) + return err == nil && info.Mode().IsRegular() +} + +// AllNames lists environment directory names under the dev-env base dir. +// Ports getAllEnvironmentNames (dev-environment-core.ts:705-723): only +// directories count; a missing base dir yields an empty slice. +func AllNames() []string { + entries, err := os.ReadDir(paths.DevEnvBase()) + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + return names +} diff --git a/internal/devenv/instancedata/instancedata_test.go b/internal/devenv/instancedata/instancedata_test.go new file mode 100644 index 000000000..d1fa7deca --- /dev/null +++ b/internal/devenv/instancedata/instancedata_test.go @@ -0,0 +1,281 @@ +package instancedata + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +func TestMigratedFromLandoRoundTrips(t *testing.T) { + in := []byte(`{"siteSlug":"foo","wpTitle":"Foo","multisite":false,"php":"8.2","migratedFromLando":"2026-07-10T00:00:00Z"}`) + d, err := parse(in) + if err != nil { + t.Fatal(err) + } + if d.MigratedFromLando != "2026-07-10T00:00:00Z" { + t.Fatalf("want marker parsed, got %q", d.MigratedFromLando) + } + out, err := serialize(d) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), `"migratedFromLando": "2026-07-10T00:00:00Z"`) { + t.Fatalf("marker not serialized: %s", out) + } +} + +func TestWriteThenReadRoundTrips(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + in := &InstanceData{ + SiteSlug: "rt", + WPTitle: "Round Trip", + Multisite: json.RawMessage("false"), + WordPress: WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: ComponentConfig{Mode: "image"}, + AppCode: ComponentConfig{Mode: "local", Dir: "/srv/rt"}, + PHP: "php:8.2", + Extra: map[string]json.RawMessage{"keepMe": json.RawMessage(`"yes"`)}, + } + if err := Write("rt", in); err != nil { + t.Fatalf("Write: %v", err) + } + if !Exists("rt") { + t.Fatalf("Exists(rt) = false after Write") + } + + out, err := Read("rt") + if err != nil { + t.Fatalf("Read: %v", err) + } + if out.SiteSlug != "rt" || out.WPTitle != "Round Trip" { + t.Fatalf("round-trip mismatch: %+v", out) + } + if string(out.Extra["keepMe"]) != `"yes"` { + t.Fatalf("Extra not preserved: %q", out.Extra["keepMe"]) + } +} + +func TestReadMissingFileReturnsError(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + _, err := Read("nope") + if err == nil { + t.Fatal("expected error reading missing env") + } +} + +func TestExistsFalseForMissing(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if Exists("ghost") { + t.Fatal("Exists(ghost) = true for missing env") + } +} + +func TestParseSerializePreservesUnknownKeys(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "unknown_keys.json")) + if err != nil { + t.Fatal(err) + } + + d, err := parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if d.SiteSlug != "example" || d.WPTitle != "Example Dev" { + t.Fatalf("known fields not parsed: %+v", d) + } + if !d.PHPMyAdmin { + t.Fatalf("phpmyadmin should be true") + } + if _, ok := d.Extra["futureKeyWeDoNotModel"]; !ok { + t.Fatalf("unknown key futureKeyWeDoNotModel not captured in Extra") + } + + out, err := serialize(d) + if err != nil { + t.Fatalf("serialize: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("re-parse: %v", err) + } + if _, ok := got["futureKeyWeDoNotModel"]; !ok { + t.Fatalf("unknown key lost on serialize") + } + if got["anotherUnknown"] != "keep-me" { + t.Fatalf("unknown scalar lost: %v", got["anotherUnknown"]) + } + if got["siteSlug"] != "example" { + t.Fatalf("known key lost: %v", got["siteSlug"]) + } +} + +func TestParseAppliesBackcompatMigrations(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "legacy_keys.json")) + if err != nil { + t.Fatal(err) + } + d, err := parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + + if string(d.Elasticsearch) != "true" { + t.Fatalf("enterpriseSearchEnabled should migrate to elasticsearch=true, got %q", d.Elasticsearch) + } + if d.AppCode.Mode != "local" || d.AppCode.Dir != "/srv/legacy" { + t.Fatalf("clientCode should migrate to appCode, got %+v", d.AppCode) + } +} + +// TestParseMigratesElasticsearchEnabledAlias covers the second legacy +// elasticsearch alias (elasticsearchEnabled), which the fixture-based test +// above does not exercise. applyBackcompat treats both enterpriseSearchEnabled +// and elasticsearchEnabled as inputs (dev-environment-core.ts:565-568). +func TestParseMigratesElasticsearchEnabledAlias(t *testing.T) { + in := []byte(`{ + "siteSlug": "es", + "wpTitle": "ES", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "appCode": { "mode": "image" }, + "mediaRedirectDomain": "", + "phpmyadmin": false, + "xdebug": false, + "php": "php:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "elasticsearchEnabled": true + }`) + d, err := parse(in) + if err != nil { + t.Fatalf("parse: %v", err) + } + if string(d.Elasticsearch) != "true" { + t.Fatalf("elasticsearchEnabled should migrate to elasticsearch=true, got %q", d.Elasticsearch) + } +} + +// TestKnownKeysMatchStructTags machine-verifies the invariant that every +// modeled struct json tag is listed in knownKeys (and vice versa). If a +// field is added to InstanceData but not to knownKeys, parse() would put +// its key into Extra AND serialize() would also write it from the struct, +// double-writing the key — silent corruption. This test catches that drift. +func TestKnownKeysMatchStructTags(t *testing.T) { + rt := reflect.TypeOf(InstanceData{}) + + tagNames := map[string]bool{} + for i := 0; i < rt.NumField(); i++ { + tag := rt.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.Split(tag, ",")[0] + if name == "" { + continue + } + tagNames[name] = true + if !knownKeys[name] { + t.Errorf("struct field %s has json key %q missing from knownKeys (would be double-written on round-trip)", rt.Field(i).Name, name) + } + } + + for k := range knownKeys { + if !tagNames[k] { + t.Errorf("knownKeys has %q with no corresponding struct json tag", k) + } + } +} + +func TestDomainAndExternalVolumesRoundTrip(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + in := &InstanceData{ + SiteSlug: "example", + Multisite: json.RawMessage("false"), + Domain: "mysite.test", + ExternalVolumes: map[string]string{"database_data": "landoproj_database_data"}, + } + if err := Write("example", in); err != nil { + t.Fatal(err) + } + got, err := Read("example") + if err != nil { + t.Fatal(err) + } + if got.Domain != "mysite.test" { + t.Fatalf("domain lost: %q", got.Domain) + } + if got.ExternalVolumes["database_data"] != "landoproj_database_data" { + t.Fatalf("external volumes lost: %+v", got.ExternalVolumes) + } +} + +func TestEnvVarsRoundTrip(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + d := &InstanceData{ + SiteSlug: "evs", + Multisite: json.RawMessage("false"), + EnvVars: map[string]string{"MY_VAR": "hello", "OTHER": "x"}, + } + if err := Write("evs", d); err != nil { + t.Fatal(err) + } + got, err := Read("evs") + if err != nil { + t.Fatal(err) + } + if got.EnvVars["MY_VAR"] != "hello" || got.EnvVars["OTHER"] != "x" { + t.Fatalf("EnvVars did not round-trip: %+v", got.EnvVars) + } +} + +func TestEmptyDomainBackfilledToLegacy(t *testing.T) { + d := &InstanceData{} // Domain == "" + applyBackcompat(d) + if d.Domain != LegacyDomain { + t.Fatalf("empty Domain = %q, want LegacyDomain %q", d.Domain, LegacyDomain) + } + d2 := &InstanceData{Domain: "vipdev.site"} + applyBackcompat(d2) + if d2.Domain != "vipdev.site" { + t.Fatalf("non-empty Domain must be left alone, got %q", d2.Domain) + } +} + +func TestAllNamesListsEnvironmentDirectories(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + // No base dir yet -> empty, no error. + if names := AllNames(); len(names) != 0 { + t.Fatalf("expected no envs, got %v", names) + } + + for _, slug := range []string{"alpha", "beta"} { + if err := Write(slug, &InstanceData{SiteSlug: slug, Multisite: json.RawMessage("false")}); err != nil { + t.Fatal(err) + } + } + // A stray file (not a directory) must be ignored. + if err := os.WriteFile(filepath.Join(paths.DevEnvBase(), "stray.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + got := AllNames() + want := map[string]bool{"alpha": true, "beta": true} + if len(got) != 2 { + t.Fatalf("AllNames() = %v, want alpha+beta only", got) + } + for _, n := range got { + if !want[n] { + t.Fatalf("unexpected env name %q in %v", n, got) + } + } +} diff --git a/internal/devenv/instancedata/testdata/legacy_keys.json b/internal/devenv/instancedata/testdata/legacy_keys.json new file mode 100644 index 000000000..9e545824c --- /dev/null +++ b/internal/devenv/instancedata/testdata/legacy_keys.json @@ -0,0 +1,16 @@ +{ + "siteSlug": "legacy", + "wpTitle": "Legacy", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "mediaRedirectDomain": "", + "phpmyadmin": false, + "xdebug": false, + "php": "php:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "enterpriseSearchEnabled": true, + "clientCode": { "mode": "local", "dir": "/srv/legacy" } +} diff --git a/internal/devenv/instancedata/testdata/unknown_keys.json b/internal/devenv/instancedata/testdata/unknown_keys.json new file mode 100644 index 000000000..7bea9bc64 --- /dev/null +++ b/internal/devenv/instancedata/testdata/unknown_keys.json @@ -0,0 +1,19 @@ +{ + "siteSlug": "example", + "wpTitle": "Example Dev", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "appCode": { "mode": "local", "dir": "/srv/example" }, + "mediaRedirectDomain": "", + "phpmyadmin": true, + "xdebug": false, + "php": "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "pullAfter": 1700000000000, + "adminPassword": "s3cret", + "futureKeyWeDoNotModel": { "nested": [1, 2, 3] }, + "anotherUnknown": "keep-me" +} diff --git a/internal/devenv/lifecycle/adopt.go b/internal/devenv/lifecycle/adopt.go new file mode 100644 index 000000000..0264fe3e5 --- /dev/null +++ b/internal/devenv/lifecycle/adopt.go @@ -0,0 +1,24 @@ +package lifecycle + +import ( + "context" + "fmt" +) + +// AdoptLando hands a pre-existing Lando environment to the Go engine. It removes +// Lando's old containers + the per-project network via `compose down +// --remove-orphans` (NO -v, so named data volumes are kept) and, when Lando owned +// the shared proxy, force-removes it so proxy.Ensure rebuilds the correct image. +// The natural same-name volume reuse (compose's `<slug>_database_data` equals the +// Lando volume) then preserves the data on the following start. +func AdoptLando(ctx context.Context, deps Deps, slug string, plan MigrationPlan) error { + if err := deps.Docker.Compose(ctx, slug, "down", "--remove-orphans"); err != nil { + return fmt.Errorf("removing old Lando containers for %q: %w", slug, err) + } + if plan.LandoProxy { + if err := deps.Proxy.ForceRemove(ctx); err != nil { + return fmt.Errorf("removing Lando proxy: %w", err) + } + } + return nil +} diff --git a/internal/devenv/lifecycle/adopt_test.go b/internal/devenv/lifecycle/adopt_test.go new file mode 100644 index 000000000..60c82998d --- /dev/null +++ b/internal/devenv/lifecycle/adopt_test.go @@ -0,0 +1,43 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" +) + +func TestAdoptLandoRemovesContainersKeepsVolumes(t *testing.T) { + d := &fakeDocker{} + events := []string{} + deps := Deps{Docker: d, Proxy: recProxy{events: &events}} + err := AdoptLando(context.Background(), deps, "example", MigrationPlan{Detected: true, LandoProxy: false}) + if err != nil { + t.Fatal(err) + } + if len(d.calls) != 1 { + t.Fatalf("want exactly one compose call, got %v", d.calls) + } + got := strings.Join(d.calls[0], " ") + if !strings.Contains(got, "down --remove-orphans") { + t.Fatalf("want `down --remove-orphans`, got %q", got) + } + if strings.Contains(got, "--volumes") || strings.Contains(got, " -v") { + t.Fatalf("adoption must NOT delete volumes, got %q", got) + } + if strings.Contains(strings.Join(events, ","), "proxy.ForceRemove") { + t.Fatal("proxy must not be force-removed when LandoProxy=false") + } +} + +func TestAdoptLandoRemovesProxyWhenLandoOwned(t *testing.T) { + d := &fakeDocker{} + events := []string{} + deps := Deps{Docker: d, Proxy: recProxy{events: &events}} + err := AdoptLando(context.Background(), deps, "example", MigrationPlan{Detected: true, LandoProxy: true}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(events, ","), "proxy.ForceRemove") { + t.Fatalf("expected proxy.ForceRemove, events=%v", events) + } +} diff --git a/internal/devenv/lifecycle/health.go b/internal/devenv/lifecycle/health.go new file mode 100644 index 000000000..807735981 --- /dev/null +++ b/internal/devenv/lifecycle/health.go @@ -0,0 +1,28 @@ +package lifecycle + +import ( + "fmt" + + "github.com/Automattic/vip/internal/devenv/proxy" +) + +// SiteURL builds the front-end HTTPS URL, including the bound https port only +// when it is not the default 443 (the place Lando drifts: printed URL vs bound +// port). +func SiteURL(slug, domain string, ports proxy.Ports) string { + host := slug + "." + domain + if ports.HTTPS == 443 { + return "https://" + host + "/" + } + return fmt.Sprintf("https://%s:%d/", host, ports.HTTPS) +} + +// Healthy probes the site URL and reports whether it returned a non-5xx, non-0 +// status (the env is up and routing). Network errors are returned. +func Healthy(p Prober, slug, domain string, ports proxy.Ports) (bool, error) { + code, err := p.Probe(SiteURL(slug, domain, ports)) + if err != nil { + return false, err + } + return code >= 200 && code < 500, nil +} diff --git a/internal/devenv/lifecycle/health_test.go b/internal/devenv/lifecycle/health_test.go new file mode 100644 index 000000000..2991b69c7 --- /dev/null +++ b/internal/devenv/lifecycle/health_test.go @@ -0,0 +1,34 @@ +package lifecycle + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/proxy" +) + +func TestSiteURLUsesBoundHTTPSPort(t *testing.T) { + if got := SiteURL("example", "vipdev.lndo.site", proxy.Ports{HTTP: 80, HTTPS: 443}); got != "https://example.vipdev.lndo.site/" { + t.Fatalf("443 should be portless: %q", got) + } + if got := SiteURL("example", "vipdev.lndo.site", proxy.Ports{HTTP: 8000, HTTPS: 4444}); got != "https://example.vipdev.lndo.site:4444/" { + t.Fatalf("fallback port must appear: %q", got) + } +} + +type fakeProber struct { + codes map[string]int +} + +func (f *fakeProber) Probe(url string) (int, error) { return f.codes[url], nil } + +func TestHealthProbesSiteURL(t *testing.T) { + url := "https://example.vipdev.lndo.site/" + p := &fakeProber{codes: map[string]int{url: 200}} + ok, err := Healthy(p, "example", "vipdev.lndo.site", proxy.Ports{HTTP: 80, HTTPS: 443}) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected healthy when site returns 200") + } +} diff --git a/internal/devenv/lifecycle/hosts.go b/internal/devenv/lifecycle/hosts.go new file mode 100644 index 000000000..d674ee080 --- /dev/null +++ b/internal/devenv/lifecycle/hosts.go @@ -0,0 +1,42 @@ +package lifecycle + +import ( + "strings" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// SubsiteHosts keeps only the discovered domains that are real subdomain-multisite +// subsites of THIS env — i.e. end in ".<slug>.<domain>" (a label in front of the +// env's own host). The env's main host and any foreign/production domains (e.g. +// from a DB imported without search-replace) are dropped, so we never write a +// host we don't own into the hosts file. Only one label is accepted because the +// current Traefik rule and certificate wildcard do not cover deeper names. +func SubsiteHosts(domains []string, v compose.View) []string { + suffix := "." + v.SiteSlug + "." + v.Domain + var out []string + for _, d := range domains { + d = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(d)), ".") + if d == "" || !strings.HasSuffix(d, suffix) { + continue + } + prefix := strings.TrimSuffix(d, suffix) + if prefix != "" && !strings.Contains(prefix, ".") { + out = append(out, d) + } + } + return out +} + +// dedupHosts returns hosts with duplicates removed, preserving first-seen order. +func dedupHosts(hosts []string) []string { + seen := map[string]bool{} + var out []string + for _, h := range hosts { + if !seen[h] { + seen[h] = true + out = append(out, h) + } + } + return out +} diff --git a/internal/devenv/lifecycle/hosts_test.go b/internal/devenv/lifecycle/hosts_test.go new file mode 100644 index 000000000..58d45a3d8 --- /dev/null +++ b/internal/devenv/lifecycle/hosts_test.go @@ -0,0 +1,41 @@ +package lifecycle + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +func TestSubsiteHostsFiltersToEnvSuffix(t *testing.T) { + v := compose.View{SiteSlug: "net", Domain: "vipdev.site"} + in := []string{ + "net.vipdev.site", // main site (no leading subdomain) -> excluded + "sub1.net.vipdev.site", // real subsite -> kept + "sub2.net.vipdev.site", // real subsite -> kept + "deep.sub.net.vipdev.site", // current router/TLS only covers one label -> dropped + "evil.example.com", // foreign/production -> dropped + "net.vipdev.site.evil.com", // suffix trick -> dropped + "evilnet.vipdev.site", // missing label boundary -> dropped + } + got := SubsiteHosts(in, v) + want := []string{"sub1.net.vipdev.site", "sub2.net.vipdev.site"} + if len(got) != len(want) { + t.Fatalf("subsiteHosts = %v, want %v", got, want) + } + seen := map[string]bool{} + for _, h := range got { + seen[h] = true + } + for _, w := range want { + if !seen[w] { + t.Fatalf("missing %q in %v", w, got) + } + } +} + +func TestDedupHosts(t *testing.T) { + got := dedupHosts([]string{"a", "b", "a", "c", "b"}) + if len(got) != 3 { + t.Fatalf("dedupHosts = %v, want 3 unique", got) + } +} diff --git a/internal/devenv/lifecycle/migrate.go b/internal/devenv/lifecycle/migrate.go new file mode 100644 index 000000000..81eccd7cb --- /dev/null +++ b/internal/devenv/lifecycle/migrate.go @@ -0,0 +1,44 @@ +package lifecycle + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/proxy" +) + +// MigrationPlan describes a detected pre-existing Lando environment for a slug. +// A zero value (Detected=false) means the env is greenfield — no adoption needed. +type MigrationPlan struct { + Detected bool + ContainerIDs []string + LandoProxy bool +} + +// landoContainerLabel is Lando's own container marker; the Go stack never sets +// it, so it is the safe discriminator between a Lando env and a Go env that +// share the same compose-project label. +const landoContainerLabel = "label=io.lando.container=TRUE" + +// DetectLandoMigration reports whether slug has a pre-existing Lando footprint +// to adopt. It is strictly scoped to this slug's compose project AND Lando-only +// labels, so it can never match another env's containers nor a Go env's own — +// the safety the disabled global volume scan lacked. +func DetectLandoMigration(ctx context.Context, d Docker, slug string) (MigrationPlan, error) { + app, err := d.ListContainers(ctx, "label=com.docker.compose.project="+slug, landoContainerLabel) + if err != nil { + return MigrationPlan{}, err + } + if len(app) == 0 { + return MigrationPlan{}, nil + } + plan := MigrationPlan{Detected: true} + for _, c := range app { + plan.ContainerIDs = append(plan.ContainerIDs, c.ID) + } + prox, err := d.ListContainers(ctx, "name="+proxy.ProxyContainerName, landoContainerLabel) + if err != nil { + return MigrationPlan{}, err + } + plan.LandoProxy = len(prox) > 0 + return plan, nil +} diff --git a/internal/devenv/lifecycle/migrate_test.go b/internal/devenv/lifecycle/migrate_test.go new file mode 100644 index 000000000..f36b09db4 --- /dev/null +++ b/internal/devenv/lifecycle/migrate_test.go @@ -0,0 +1,78 @@ +package lifecycle + +import ( + "context" + "testing" +) + +func TestDetectLandoMigrationDetectsThisSlugsContainers(t *testing.T) { + d := &fakeDocker{containers: []fakeContainer{ + {id: "c1", name: "example_php_1", project: "example", lando: true}, + {id: "c2", name: "example_database_1", project: "example", lando: true}, + }} + got, err := DetectLandoMigration(context.Background(), d, "example") + if err != nil { + t.Fatal(err) + } + if !got.Detected { + t.Fatal("expected Detected=true") + } + if len(got.ContainerIDs) != 2 { + t.Fatalf("want 2 container IDs, got %+v", got.ContainerIDs) + } +} + +// Regression guard for the hijack bug: detecting "myslug" must NEVER match +// another env's ("myapp") Lando containers. +func TestDetectLandoMigrationNeverAdoptsOtherEnv(t *testing.T) { + d := &fakeDocker{containers: []fakeContainer{ + {id: "c1", name: "myapp_database_1", project: "myapp", lando: true}, + }} + got, err := DetectLandoMigration(context.Background(), d, "myslug") + if err != nil { + t.Fatal(err) + } + if got.Detected { + t.Fatalf("must not detect another env, got %+v", got) + } +} + +// A Go env's own containers share the project label but carry NO io.lando label, +// so they must never be treated as a Lando env. +func TestDetectLandoMigrationIgnoresGoContainers(t *testing.T) { + d := &fakeDocker{containers: []fakeContainer{ + {id: "c1", name: "example_database_1", project: "example", lando: false}, + }} + got, err := DetectLandoMigration(context.Background(), d, "example") + if err != nil { + t.Fatal(err) + } + if got.Detected { + t.Fatalf("Go containers must not be detected, got %+v", got) + } +} + +func TestDetectLandoMigrationFlagsLandoProxy(t *testing.T) { + d := &fakeDocker{containers: []fakeContainer{ + {id: "c1", name: "example_php_1", project: "example", lando: true}, + {id: "p", name: "vip-dev-env-proxy", project: "", lando: true}, + }} + got, err := DetectLandoMigration(context.Background(), d, "example") + if err != nil { + t.Fatal(err) + } + if !got.LandoProxy { + t.Fatal("expected LandoProxy=true") + } +} + +func TestDetectLandoMigrationNoneWhenNoContainers(t *testing.T) { + d := &fakeDocker{} + got, err := DetectLandoMigration(context.Background(), d, "example") + if err != nil { + t.Fatal(err) + } + if got.Detected { + t.Fatalf("expected no detection, got %+v", got) + } +} diff --git a/internal/devenv/lifecycle/pull.go b/internal/devenv/lifecycle/pull.go new file mode 100644 index 000000000..d4cd9c429 --- /dev/null +++ b/internal/devenv/lifecycle/pull.go @@ -0,0 +1,19 @@ +package lifecycle + +import "time" + +// pullInterval is Lando's pullAfter window: re-pull images at most weekly. +const pullInterval = 7 * 24 * time.Hour + +// ShouldPull reports whether `compose pull` should run before `up`. lastPull is +// the Unix time of the last pull (nil = never). It pulls when the registry is +// reachable AND (never pulled OR last pull is older than pullInterval). +func ShouldPull(now time.Time, lastPull *int64, registryReachable bool) bool { + if !registryReachable { + return false + } + if lastPull == nil { + return true + } + return now.Sub(time.Unix(*lastPull, 0)) >= pullInterval +} diff --git a/internal/devenv/lifecycle/pull_test.go b/internal/devenv/lifecycle/pull_test.go new file mode 100644 index 000000000..4e3e1001f --- /dev/null +++ b/internal/devenv/lifecycle/pull_test.go @@ -0,0 +1,29 @@ +package lifecycle + +import ( + "testing" + "time" +) + +func TestShouldPull(t *testing.T) { + now := time.Unix(1_000_000_000, 0) + weekAgo := now.Add(-8 * 24 * time.Hour).Unix() + yesterday := now.Add(-24 * time.Hour).Unix() + + // never pulled + registry reachable -> pull + if !ShouldPull(now, nil, true) { + t.Fatal("never-pulled+reachable should pull") + } + // pulled >7d ago + reachable -> pull + if !ShouldPull(now, &weekAgo, true) { + t.Fatal("stale + reachable should pull") + } + // pulled <7d ago -> skip + if ShouldPull(now, &yesterday, true) { + t.Fatal("fresh should not pull") + } + // registry unreachable -> skip even if stale + if ShouldPull(now, &weekAgo, false) { + t.Fatal("unreachable should not pull") + } +} diff --git a/internal/devenv/lifecycle/setup.go b/internal/devenv/lifecycle/setup.go new file mode 100644 index 000000000..777a49ec9 --- /dev/null +++ b/internal/devenv/lifecycle/setup.go @@ -0,0 +1,35 @@ +package lifecycle + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// setupService is the container the post-start steps run in (the php service). +const setupService = "php" + +// setupUser is the non-root service user the `run:` steps execute as. The php +// image's default user is root, so a non-root step must set this explicitly — +// otherwise wp-cli aborts with "running as root" (mirrors Lando running `run:` +// steps as the service user, distinct from `run_as_root:`). +const setupUser = "www-data" + +// RunSetupSteps executes the compose SetupSteps in order. run_as_root steps run +// as container root; run steps run as the service user (www-data). Both go +// through `compose exec -T -e TERM=xterm -u <user> php sh -c "<command>"`: +// -T disables TTY allocation, and TERM is set so setup.sh's `tput` calls don't +// warn ("No value for $TERM and no -T specified") on every line. +func RunSetupSteps(ctx context.Context, d Docker, project string, steps []compose.SetupStep) error { + for _, s := range steps { + user := setupUser + if s.AsRoot { + user = "root" + } + args := []string{"exec", "-T", "-e", "TERM=xterm", "-u", user, setupService, "sh", "-c", s.Command} + if err := d.Compose(ctx, project, args...); err != nil { + return err + } + } + return nil +} diff --git a/internal/devenv/lifecycle/setup_test.go b/internal/devenv/lifecycle/setup_test.go new file mode 100644 index 000000000..93e043041 --- /dev/null +++ b/internal/devenv/lifecycle/setup_test.go @@ -0,0 +1,46 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +func TestRunSetupStepsIssuesExecs(t *testing.T) { + d := &fakeDocker{} + steps := []compose.SetupStep{ + {AsRoot: true, Command: "chown www-data:www-data /wp"}, + {AsRoot: false, Command: "sh /dev-tools/setup.sh --host database"}, + } + if err := RunSetupSteps(context.Background(), d, "proj", steps); err != nil { + t.Fatal(err) + } + if len(d.calls) != 2 { + t.Fatalf("want 2 exec calls, got %d: %v", len(d.calls), d.calls) + } + root := strings.Join(d.calls[0], " ") + if !strings.Contains(root, "exec") || !strings.Contains(root, "-u root") || !strings.Contains(root, "php") { + t.Fatalf("root step not exec -u root php: %s", root) + } + // TERM is set so setup.sh's tput calls don't warn on every line. + if !strings.Contains(root, "-e TERM=xterm") { + t.Fatalf("setup step missing TERM env: %s", root) + } + if !strings.Contains(root, "chown www-data:www-data /wp") { + t.Fatalf("root command missing: %s", root) + } + user := strings.Join(d.calls[1], " ") + if strings.Contains(user, "-u root") { + t.Fatalf("non-root step must not use -u root: %s", user) + } + // The php image runs as root by default, so the non-root step MUST set + // -u www-data explicitly or wp-cli refuses to run ("running as root"). + if !strings.Contains(user, "-u www-data") { + t.Fatalf("non-root step must run as www-data: %s", user) + } + if !strings.Contains(user, "sh -c") || !strings.Contains(user, "setup.sh") { + t.Fatalf("user step not exec sh -c: %s", user) + } +} diff --git a/internal/devenv/lifecycle/start.go b/internal/devenv/lifecycle/start.go new file mode 100644 index 000000000..fc26ad6a1 --- /dev/null +++ b/internal/devenv/lifecycle/start.go @@ -0,0 +1,132 @@ +package lifecycle + +import ( + "context" + "strings" + "time" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +// StartParams are the resolved inputs Start needs (the root package builds these +// from instance-data + materialization). +type StartParams struct { + Project string + View compose.View + CertSANs []string + HostsAdd []string // hostnames for /etc/hosts; wildcards are filtered by Start before elevation. + InitServices []string + SetupSteps []compose.SetupStep + Pull bool + SkipRebuild bool // omit --force-recreate (only start non-running services) + GOOS string + PollEvery time.Duration // 0 => default; tests pass tiny +} + +// nonWildcard drops wildcard SANs — valid for TLS, invalid as /etc/hosts entries. +func nonWildcard(hosts []string) []string { + var out []string + for _, h := range hosts { + if !strings.Contains(h, "*") { + out = append(out, h) + } + } + return out +} + +// Start brings an environment up: ensure proxy + CA + per-env cert, extract the +// CA, compose up, wait for init services, run setup steps, then trust the CA + +// write /etc/hosts under ONE elevation (after setup, so wp_blogs is queryable +// for subdomain-multisite subsites). Returns the bound proxy ports. +func Start(ctx context.Context, deps Deps, p StartParams) (proxy.Ports, error) { + ports, err := deps.Proxy.Ensure(ctx, proxy.EnsureOptions{Domain: p.View.Domain}) + if err != nil { + return proxy.Ports{}, err + } + if err := deps.Proxy.EnsureCA(ctx); err != nil { + return proxy.Ports{}, err + } + if err := deps.Proxy.EnsureCert(ctx, proxy.CertRequest{ + Basename: p.Project, + CommonName: p.View.SiteSlug + "." + p.View.Domain, + SANs: p.CertSANs, + }); err != nil { + return proxy.Ports{}, err + } + caPath, err := deps.Proxy.ExtractCA(ctx, proxy.CAHostPath()) + if err != nil { + return proxy.Ports{}, err + } + + // --force-recreate is required for correct re-starts. The wordpress init + // one-shot rsyncs (--delete) into ./wordpress, which removes the nested + // /wp/config|log|uploads mountpoint dirs created during the previous start. + // Without --force-recreate, `up` re-runs that init but leaves the php/nginx + // containers as-is, so their nested bind mounts break and the run_as_root + // chown fails with "/wp/config: No such file or directory". Recreating all + // containers re-establishes those mounts after the rsync (matches Lando, + // which recreates app containers on each start). Named volumes (DB data, + // etc.) persist across recreate, so no data is lost. + upArgs := []string{"up", "-d", "--remove-orphans"} + if !p.SkipRebuild { + upArgs = append(upArgs, "--force-recreate") + } + if !p.Pull { + upArgs = append(upArgs, "--pull", "never") + } + if err := deps.Docker.Compose(ctx, p.Project, upArgs...); err != nil { + return proxy.Ports{}, err + } + if len(p.InitServices) > 0 { + if err := WaitForInit(ctx, deps.Docker, p.Project, p.InitServices, p.PollEvery); err != nil { + return proxy.Ports{}, err + } + } + if err := RunSetupSteps(ctx, deps.Docker, p.Project, p.SetupSteps); err != nil { + return proxy.Ports{}, err + } + + // Hosts + CA trust happen ONCE here, after setup, so wp_blogs is queryable + // for subdomain-multisite subsites. wp-cli setup talks to the DB directly and + // needs no DNS, so nothing earlier needs the hosts entries. + if err := applyHostsAndTrust(ctx, deps, p, caPath); err != nil { + return proxy.Ports{}, err + } + return ports, nil +} + +// applyHostsAndTrust computes the env's desired hostnames (fixed + discovered +// subdomain-multisite subsites) and, under a single elevation, trusts the CA +// and/or writes the managed hosts block — but only when something is missing. +func applyHostsAndTrust(ctx context.Context, deps Deps, p StartParams, caPath string) error { + hosts := nonWildcard(p.HostsAdd) + if p.View.MultisiteEnabled && p.View.MultisiteSubdomain && deps.Subsites != nil { + domains, err := deps.Subsites.ListSubsiteDomains(ctx, p.Project, setupService) + if err == nil { + hosts = append(hosts, SubsiteHosts(domains, p.View)...) + } + // Non-fatal: offline subsite resolution is best-effort; the public + // wildcard covers subsites online. + } + hosts = dedupHosts(hosts) + + // Only elevate (one sudo prompt) when something actually needs changing: the + // CA isn't trusted yet, or the /etc/hosts entries are missing. A previously + // trusted CA + present hosts => no prompt (the common re-start case), and we + // stop re-adding duplicate CA entries to the keychain. + needTrust := caPath != "" && !deps.Elevator.CATrusted(caPath) + needHosts := len(hosts) > 0 && !deps.Elevator.HostsPresent(hosts) + if !needTrust && !needHosts { + return nil + } + plan := hostops.PrivilegedPlan{GOOS: p.GOOS} + if needTrust { + plan.CAPath = caPath + } + if needHosts { + plan.HostsAdd = hosts + } + return deps.Elevator.Apply(plan) +} diff --git a/internal/devenv/lifecycle/start_test.go b/internal/devenv/lifecycle/start_test.go new file mode 100644 index 000000000..f96c98f55 --- /dev/null +++ b/internal/devenv/lifecycle/start_test.go @@ -0,0 +1,350 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +type recProxy struct{ events *[]string } + +func (p recProxy) Ensure(ctx context.Context, o proxy.EnsureOptions) (proxy.Ports, error) { + *p.events = append(*p.events, "proxy.Ensure") + return proxy.Ports{HTTP: 80, HTTPS: 443}, nil +} +func (p recProxy) EnsureCA(ctx context.Context) error { + *p.events = append(*p.events, "EnsureCA") + return nil +} +func (p recProxy) EnsureCert(ctx context.Context, r proxy.CertRequest) error { + *p.events = append(*p.events, "EnsureCert") + return nil +} +func (p recProxy) ExtractCA(ctx context.Context, dest string) (string, error) { + *p.events = append(*p.events, "ExtractCA") + return dest, nil +} +func (p recProxy) Cleanup(ctx context.Context) error { + *p.events = append(*p.events, "proxy.Cleanup") + return nil +} +func (p recProxy) RemoveOrphan(ctx context.Context) error { + *p.events = append(*p.events, "proxy.RemoveOrphan") + return nil +} +func (p recProxy) ForceRemove(ctx context.Context) error { + *p.events = append(*p.events, "proxy.ForceRemove") + return nil +} + +type recElevator struct { + events *[]string + last hostops.PrivilegedPlan + // caTrusted/hostsPresent drive the skip-elevation checks. Default false so + // existing tests still see Elevator.Apply called. + caTrusted bool + hostsPresent bool + // lastPlan mirrors last for ordering tests; appliedBeforeSetup records + // whether Apply ran before the Docker fake's setup exec (true => bug). + lastPlan hostops.PrivilegedPlan + appliedBeforeSetup bool + dk *recDocker // optional: lets Apply observe setup ordering +} + +func (e *recElevator) Apply(plan hostops.PrivilegedPlan) error { + *e.events = append(*e.events, "Elevator.Apply") + e.last = plan + e.lastPlan = plan + if e.dk != nil { + e.appliedBeforeSetup = !e.dk.setupDone + } + return nil +} + +func (e *recElevator) CATrusted(string) bool { return e.caTrusted } +func (e *recElevator) HostsPresent([]string) bool { return e.hostsPresent } + +// recDocker records compose subcommands as "compose:<sub>". +type recDocker struct { + events *[]string + ps [][]ServiceState + composeArgs [][]string + // setupDone is set once RunSetupSteps' exec has run (a "compose exec"). + setupDone bool +} + +func (d *recDocker) Compose(ctx context.Context, project string, args ...string) error { + if len(args) > 0 { + *d.events = append(*d.events, "compose:"+args[0]) + if args[0] == "exec" { + d.setupDone = true + } + } + d.composeArgs = append(d.composeArgs, append([]string{}, args...)) + return nil +} +func (d *recDocker) ComposePS(ctx context.Context, project string) ([]ServiceState, error) { + if len(d.ps) == 0 { + return nil, nil + } + out := d.ps[0] + d.ps = d.ps[1:] + return out, nil +} +func (d *recDocker) ListVolumes(ctx context.Context) ([]string, error) { return nil, nil } +func (d *recDocker) ListContainers(ctx context.Context, filters ...string) ([]Container, error) { + return nil, nil +} + +func assertOrder(t *testing.T, events []string, want ...string) { + t.Helper() + idx := 0 + for _, e := range events { + if idx < len(want) && e == want[idx] { + idx++ + } + } + if idx != len(want) { + t.Fatalf("events %v did not contain ordered subsequence %v (matched %d)", events, want, idx) + } +} + +func TestStartOrdersOperations(t *testing.T) { + var events []string + d := &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}} + el := &recElevator{events: &events} + deps := Deps{ + Docker: d, + Proxy: recProxy{events: &events}, + Elevator: el, + Prober: &fakeProber{codes: map[string]int{"https://example.vipdev.lndo.site/": 200}}, + } + view := compose.View{SiteSlug: "example", Domain: "vipdev.lndo.site"} + ports, err := Start(context.Background(), deps, StartParams{ + Project: "example", + View: view, + CertSANs: []string{"example.vipdev.lndo.site", "*.vipdev.lndo.site"}, + HostsAdd: []string{"mysite.test", "*.vipdev.lndo.site"}, + InitServices: []string{"wordpress"}, + SetupSteps: []compose.SetupStep{{AsRoot: false, Command: "true"}}, + Pull: false, + PollEvery: time.Millisecond, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + if ports.HTTPS != 443 { + t.Fatalf("ports not returned: %+v", ports) + } + assertOrder(t, events, + "proxy.Ensure", "EnsureCA", "EnsureCert", "ExtractCA", + "compose:up", "compose:exec", "Elevator.Apply") + if len(el.last.HostsAdd) != 1 || el.last.HostsAdd[0] != "mysite.test" { + t.Fatalf("expected only the non-wildcard host in HostsAdd, got %v", el.last.HostsAdd) + } + for _, h := range el.last.HostsAdd { + if strings.Contains(h, "*") { + t.Fatalf("wildcard leaked into HostsAdd: %v", el.last.HostsAdd) + } + } + // `up` must --force-recreate so the php/nginx nested mounts are rebuilt + // after the wordpress init's rsync --delete (else re-starts break the chown). + var sawForceRecreate bool + for _, args := range d.composeArgs { + if len(args) > 0 && args[0] == "up" { + for _, a := range args { + if a == "--force-recreate" { + sawForceRecreate = true + } + } + } + } + if !sawForceRecreate { + t.Fatalf("compose up must include --force-recreate; got %v", d.composeArgs) + } +} + +func TestNonWildcard(t *testing.T) { + cases := []struct { + in []string + want []string + }{ + {[]string{"*.example.com"}, nil}, + {[]string{"mysite.test", "*.example.com"}, []string{"mysite.test"}}, + {[]string{"a.test", "b.test"}, []string{"a.test", "b.test"}}, + } + for i, c := range cases { + got := nonWildcard(c.in) + if len(got) != len(c.want) { + t.Fatalf("case %d: got %v, want %v", i, got, c.want) + } + for j := range got { + if got[j] != c.want[j] { + t.Fatalf("case %d: got %v, want %v", i, got, c.want) + } + } + } +} + +// TestStartSkipsElevationWhenTrustedAndHostsPresent verifies the #1 fix: a +// re-start with the CA already trusted and hosts present does NOT elevate (no +// sudo prompt, no duplicate keychain entries). +func TestStartSkipsElevationWhenTrustedAndHostsPresent(t *testing.T) { + var events []string + d := &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}} + el := &recElevator{events: &events, caTrusted: true, hostsPresent: true} + deps := Deps{ + Docker: d, + Proxy: recProxy{events: &events}, + Elevator: el, + Prober: &fakeProber{codes: map[string]int{"https://example.vipdev.lndo.site/": 200}}, + } + if _, err := Start(context.Background(), deps, StartParams{ + Project: "example", + View: compose.View{SiteSlug: "example", Domain: "vipdev.lndo.site"}, + CertSANs: []string{"example.vipdev.lndo.site"}, + HostsAdd: []string{"mysite.test"}, + InitServices: []string{"wordpress"}, + SetupSteps: []compose.SetupStep{{AsRoot: false, Command: "true"}}, + PollEvery: time.Millisecond, + }); err != nil { + t.Fatal(err) + } + for _, e := range events { + if e == "Elevator.Apply" { + t.Fatalf("Elevator.Apply must be skipped when CA trusted + hosts present; events=%v", events) + } + } +} + +// TestStartElevatesHostsOnlyWhenTrusted verifies that when the CA is already +// trusted but hosts are missing, Start elevates for the hosts only and does NOT +// re-trust the CA (no CAPath in the plan). +func TestStartElevatesHostsOnlyWhenTrusted(t *testing.T) { + var events []string + d := &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}} + el := &recElevator{events: &events, caTrusted: true, hostsPresent: false} + deps := Deps{ + Docker: d, + Proxy: recProxy{events: &events}, + Elevator: el, + Prober: &fakeProber{codes: map[string]int{"https://example.vipdev.lndo.site/": 200}}, + } + if _, err := Start(context.Background(), deps, StartParams{ + Project: "example", + View: compose.View{SiteSlug: "example", Domain: "vipdev.lndo.site"}, + CertSANs: []string{"example.vipdev.lndo.site"}, + HostsAdd: []string{"mysite.test"}, + InitServices: []string{"wordpress"}, + SetupSteps: []compose.SetupStep{{AsRoot: false, Command: "true"}}, + PollEvery: time.Millisecond, + }); err != nil { + t.Fatal(err) + } + if el.last.CAPath != "" { + t.Fatalf("CA already trusted: must not re-trust, got CAPath=%q", el.last.CAPath) + } + if len(el.last.HostsAdd) != 1 || el.last.HostsAdd[0] != "mysite.test" { + t.Fatalf("expected hosts-only elevation, got HostsAdd=%v", el.last.HostsAdd) + } +} + +type fakeSubsites struct { + domains []string + calls int +} + +func (f *fakeSubsites) ListSubsiteDomains(_ context.Context, _, _ string) ([]string, error) { + f.calls++ + return f.domains, nil +} + +func TestSubsiteListerSatisfied(t *testing.T) { + var _ SubsiteLister = &fakeSubsites{} +} + +func TestStartElevatesAfterSetupWithSubsites(t *testing.T) { + var events []string + dk := &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}} + el := &recElevator{events: &events, dk: dk} // caTrusted/hostsPresent default false => Apply runs + subs := &fakeSubsites{domains: []string{"s1.net.vipdev.site", "foreign.com"}} + deps := Deps{Docker: dk, Proxy: recProxy{events: &events}, Elevator: el, Subsites: subs} + view := compose.View{SiteSlug: "net", Domain: "vipdev.site", MultisiteEnabled: true, MultisiteSubdomain: true} + _, err := Start(context.Background(), deps, StartParams{ + Project: "net", + View: view, + HostsAdd: []string{"net.vipdev.site"}, + SetupSteps: []compose.SetupStep{{AsRoot: false, Command: "true"}}, + GOOS: "darwin", + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + if subs.calls != 1 { + t.Fatalf("expected 1 subsite query, got %d", subs.calls) + } + got := map[string]bool{} + for _, h := range el.lastPlan.HostsAdd { + got[h] = true + } + if !got["net.vipdev.site"] || !got["s1.net.vipdev.site"] || got["foreign.com"] { + t.Fatalf("Apply HostsAdd = %v; want fixed+subsite, no foreign", el.lastPlan.HostsAdd) + } + if el.appliedBeforeSetup { + t.Fatal("elevation ran before setup; must run after") + } +} + +func TestStartSkipsSubsiteQueryForSingleSite(t *testing.T) { + var events []string + subs := &fakeSubsites{} + deps := Deps{ + Docker: &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}}, + Proxy: recProxy{events: &events}, + Elevator: &recElevator{events: &events}, + Subsites: subs, + } + view := compose.View{SiteSlug: "solo", Domain: "vipdev.site"} // not multisite + _, _ = Start(context.Background(), deps, StartParams{Project: "solo", View: view, HostsAdd: []string{"solo.vipdev.site"}, GOOS: "darwin"}) + if subs.calls != 0 { + t.Fatalf("single-site must not query subsites, got %d calls", subs.calls) + } +} + +// TestStartSkipRebuildOmitsForceRecreate verifies --skip-rebuild drops +// --force-recreate (only start non-running services). +func TestStartSkipRebuildOmitsForceRecreate(t *testing.T) { + var events []string + d := &recDocker{events: &events, ps: [][]ServiceState{{{Service: "wordpress", State: "exited", ExitCode: 0}}}} + deps := Deps{ + Docker: d, + Proxy: recProxy{events: &events}, + Elevator: &recElevator{events: &events, caTrusted: true, hostsPresent: true}, + Prober: &fakeProber{codes: map[string]int{"https://example.vipdev.lndo.site/": 200}}, + } + if _, err := Start(context.Background(), deps, StartParams{ + Project: "example", + View: compose.View{SiteSlug: "example", Domain: "vipdev.lndo.site"}, + CertSANs: []string{"example.vipdev.lndo.site"}, + InitServices: []string{"wordpress"}, + SetupSteps: []compose.SetupStep{{AsRoot: false, Command: "true"}}, + SkipRebuild: true, + PollEvery: time.Millisecond, + }); err != nil { + t.Fatal(err) + } + for _, args := range d.composeArgs { + if len(args) > 0 && args[0] == "up" { + for _, a := range args { + if a == "--force-recreate" { + t.Fatalf("--skip-rebuild must omit --force-recreate; got %v", args) + } + } + } + } +} diff --git a/internal/devenv/lifecycle/teardown.go b/internal/devenv/lifecycle/teardown.go new file mode 100644 index 000000000..6339b1149 --- /dev/null +++ b/internal/devenv/lifecycle/teardown.go @@ -0,0 +1,32 @@ +package lifecycle + +import "context" + +// Stop stops the env's containers, keeping volumes + state. +func Stop(ctx context.Context, d Docker, project string) error { + return d.Compose(ctx, project, "stop") +} + +// Destroy removes the env's containers + its own (non-external) volumes via +// compose down -v. Migrated volumes are declared external, which compose refuses +// to delete, so original Lando data survives. When remaining == 0 (no other env +// left), the shared proxy is cleaned up too. The caller removes on-disk files + +// instance-data and recomputes /etc/hosts. +func Destroy(ctx context.Context, d Docker, pr Proxy, project string, remaining int) error { + if err := d.Compose(ctx, project, "down", "--volumes", "--remove-orphans"); err != nil { + return err + } + if remaining == 0 { + return pr.Cleanup(ctx) + } + return nil +} + +// Rebuild recreates containers keeping volumes: down (no -v) -> orphan guard. +// The caller re-runs the up + init-wait + setup sequence (via Start) afterward. +func Rebuild(ctx context.Context, d Docker, pr Proxy, project string) error { + if err := d.Compose(ctx, project, "down", "--remove-orphans"); err != nil { + return err + } + return pr.RemoveOrphan(ctx) +} diff --git a/internal/devenv/lifecycle/teardown_test.go b/internal/devenv/lifecycle/teardown_test.go new file mode 100644 index 000000000..1573e6c8b --- /dev/null +++ b/internal/devenv/lifecycle/teardown_test.go @@ -0,0 +1,83 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" +) + +func downArgs(t *testing.T, d *recDocker) string { + t.Helper() + for _, a := range d.composeArgs { + if len(a) > 0 && a[0] == "down" { + return strings.Join(a, " ") + } + } + t.Fatalf("no compose down call recorded: %v", d.composeArgs) + return "" +} + +func TestStopIssuesComposeStop(t *testing.T) { + ev := []string{} + d := &recDocker{events: &ev} + if err := Stop(context.Background(), d, "proj"); err != nil { + t.Fatal(err) + } + if len(ev) != 1 || ev[0] != "compose:stop" { + t.Fatalf("want [compose:stop], got %v", ev) + } +} + +func TestDestroyDownsAndCleansProxyWhenLast(t *testing.T) { + var ev []string + d := &recDocker{events: &ev} + pr := recProxy{events: &ev} + if err := Destroy(context.Background(), d, pr, "proj", 0); err != nil { + t.Fatal(err) + } + joined := strings.Join(ev, ",") + if !strings.Contains(joined, "compose:down") { + t.Fatalf("destroy must compose down: %v", ev) + } + if !strings.Contains(joined, "proxy.Cleanup") { + t.Fatalf("destroy with remaining=0 must clean the proxy: %v", ev) + } + if da := downArgs(t, d); !strings.Contains(da, "--volumes") { + t.Fatalf("destroy down must pass --volumes (remove env-owned volumes): %q", da) + } +} + +func TestDestroyKeepsProxyWhenOtherEnvsRemain(t *testing.T) { + var ev []string + d := &recDocker{events: &ev} + pr := recProxy{events: &ev} + if err := Destroy(context.Background(), d, pr, "proj", 2); err != nil { + t.Fatal(err) + } + joined := strings.Join(ev, ",") + if !strings.Contains(joined, "compose:down") { + t.Fatalf("destroy must compose down: %v", ev) + } + if strings.Contains(joined, "proxy.Cleanup") { + t.Fatalf("destroy with remaining>0 must NOT clean the proxy: %v", ev) + } +} + +func TestRebuildDownsAndGuardsOrphan(t *testing.T) { + var ev []string + d := &recDocker{events: &ev} + pr := recProxy{events: &ev} + if err := Rebuild(context.Background(), d, pr, "proj"); err != nil { + t.Fatal(err) + } + joined := strings.Join(ev, ",") + if !strings.Contains(joined, "compose:down") { + t.Fatalf("rebuild must compose down: %v", ev) + } + if !strings.Contains(joined, "proxy.RemoveOrphan") { + t.Fatalf("rebuild must guard against orphan proxy: %v", ev) + } + if da := downArgs(t, d); strings.Contains(da, "--volumes") { + t.Fatalf("rebuild down must NOT pass --volumes (keep data): %q", da) + } +} diff --git a/internal/devenv/lifecycle/types.go b/internal/devenv/lifecycle/types.go new file mode 100644 index 000000000..cbd085280 --- /dev/null +++ b/internal/devenv/lifecycle/types.go @@ -0,0 +1,86 @@ +// Package lifecycle orchestrates vip dev-env environments (start/stop/rebuild/ +// destroy/purge/info/health) and one-time Lando migration (spec §6/§10). It is +// pure control-flow over injected interfaces (Deps) so the orchestration order +// is unit-testable with fakes; the root internal/devenv package supplies the +// real adapters. +package lifecycle + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/hostops" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +// ServiceState is one service's status from `docker compose ps`. +type ServiceState struct { + Service string + State string // "running", "exited", ... + ExitCode int +} + +// Container is the subset of `docker ps` a caller needs to identify a container. +type Container struct { + ID string + Name string +} + +// Docker is the compose/volume surface the lifecycle needs. +type Docker interface { + Compose(ctx context.Context, project string, args ...string) error + ComposePS(ctx context.Context, project string) ([]ServiceState, error) + ListVolumes(ctx context.Context) ([]string, error) + // ListContainers runs `docker ps -a` with the given raw `--filter` values + // (e.g. "label=com.docker.compose.project=foo") and returns the matches. + ListContainers(ctx context.Context, filters ...string) ([]Container, error) +} + +// Proxy wraps the proxy package so lifecycle control-flow is fakeable. +type Proxy interface { + Ensure(ctx context.Context, opts proxy.EnsureOptions) (proxy.Ports, error) + EnsureCA(ctx context.Context) error + EnsureCert(ctx context.Context, req proxy.CertRequest) error + ExtractCA(ctx context.Context, dest string) (string, error) + Cleanup(ctx context.Context) error + RemoveOrphan(ctx context.Context) error + // ForceRemove force-removes the shared proxy so Ensure rebuilds it (Lando + // adoption: Lando's proxy shares our container name). + ForceRemove(ctx context.Context) error +} + +// Elevator runs the single privileged operation (trust CA + /etc/hosts) and +// exposes non-privileged "already done?" checks so Start can skip the sudo +// prompt when nothing needs changing. +type Elevator interface { + Apply(plan hostops.PrivilegedPlan) error + // CATrusted reports whether caPath's CA is already trusted by the system. + CATrusted(caPath string) bool + // HostsPresent reports whether the managed /etc/hosts block already lists + // every hostname in hosts. + HostsPresent(hosts []string) bool +} + +// Prober performs an HTTP GET for health checks, returning the status code. +type Prober interface { + Probe(url string) (int, error) +} + +// SubsiteLister lists the domains of a multisite's subsites by querying the +// running env (e.g. `wp site list`). Used post-setup to add subdomain-multisite +// subsite hosts to the managed hosts block for offline resolution. +type SubsiteLister interface { + ListSubsiteDomains(ctx context.Context, project, service string) ([]string, error) +} + +// Deps bundles every injected dependency the lifecycle engine uses. +type Deps struct { + Docker Docker + Proxy Proxy + Elevator Elevator + // Prober is reserved for the health-watch flow (consumed by the command layer + // via Healthy); the Start/Stop engine funcs don't read it yet. + Prober Prober + // Subsites discovers multisite subsite domains; nil disables discovery + // (single-site/subdirectory envs never call it). + Subsites SubsiteLister +} diff --git a/internal/devenv/lifecycle/waiter.go b/internal/devenv/lifecycle/waiter.go new file mode 100644 index 000000000..892d4e927 --- /dev/null +++ b/internal/devenv/lifecycle/waiter.go @@ -0,0 +1,49 @@ +package lifecycle + +import ( + "context" + "fmt" + "time" +) + +// initPollInterval is the default gap between ComposePS polls. +const initPollInterval = 2 * time.Second + +// WaitForInit blocks until every service in initServices has exited 0. A +// non-zero exit fails immediately. pollEvery<=0 uses initPollInterval; tests +// pass a tiny value. Honors ctx cancellation. +func WaitForInit(ctx context.Context, d Docker, project string, initServices []string, pollEvery time.Duration) error { + if pollEvery <= 0 { + pollEvery = initPollInterval + } + want := map[string]bool{} + for _, s := range initServices { + want[s] = true + } + for { + states, err := d.ComposePS(ctx, project) + if err != nil { + return fmt.Errorf("lifecycle: ps while waiting for init services: %w", err) + } + done := map[string]bool{} + for _, s := range states { + if !want[s.Service] { + continue + } + if s.State == "exited" { + if s.ExitCode != 0 { + return fmt.Errorf("lifecycle: init service %q exited %d", s.Service, s.ExitCode) + } + done[s.Service] = true + } + } + if len(done) == len(want) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollEvery): + } + } +} diff --git a/internal/devenv/lifecycle/waiter_test.go b/internal/devenv/lifecycle/waiter_test.go new file mode 100644 index 000000000..2b19082eb --- /dev/null +++ b/internal/devenv/lifecycle/waiter_test.go @@ -0,0 +1,98 @@ +package lifecycle + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// fakeContainer is fakeDocker's internal record; ListContainers filters over it. +type fakeContainer struct { + id, name, project string + lando bool +} + +// fakeDocker scripts ComposePS results per poll and records Compose calls. +type fakeDocker struct { + psQueue [][]ServiceState + psErr error + calls [][]string + volumes []string + containers []fakeContainer +} + +func (f *fakeDocker) Compose(ctx context.Context, project string, args ...string) error { + f.calls = append(f.calls, append([]string{project}, args...)) + return nil +} +func (f *fakeDocker) ComposePS(ctx context.Context, project string) ([]ServiceState, error) { + if f.psErr != nil { + return nil, f.psErr + } + if len(f.psQueue) == 0 { + return nil, nil + } + out := f.psQueue[0] + f.psQueue = f.psQueue[1:] + return out, nil +} +func (f *fakeDocker) ListVolumes(ctx context.Context) ([]string, error) { return f.volumes, nil } +func (f *fakeDocker) ListContainers(ctx context.Context, filters ...string) ([]Container, error) { + var out []Container + for _, c := range f.containers { + if fakeContainerMatches(c, filters) { + out = append(out, Container{ID: c.id, Name: c.name}) + } + } + return out, nil +} + +func fakeContainerMatches(c fakeContainer, filters []string) bool { + for _, f := range filters { + switch { + case strings.HasPrefix(f, "label=com.docker.compose.project="): + if c.project != strings.TrimPrefix(f, "label=com.docker.compose.project=") { + return false + } + case f == "label=io.lando.container=TRUE": + if !c.lando { + return false + } + case strings.HasPrefix(f, "name="): + if c.name != strings.TrimPrefix(f, "name=") { + return false + } + default: + return false + } + } + return true +} + +func TestWaitForInitSucceedsWhenAllExitZero(t *testing.T) { + d := &fakeDocker{psQueue: [][]ServiceState{ + {{Service: "wordpress", State: "running", ExitCode: 0}}, // not done yet + {{Service: "wordpress", State: "exited", ExitCode: 0}}, // done + }} + if err := WaitForInit(context.Background(), d, "proj", []string{"wordpress"}, time.Millisecond); err != nil { + t.Fatalf("WaitForInit: %v", err) + } +} + +func TestWaitForInitFailsOnNonZeroExit(t *testing.T) { + d := &fakeDocker{psQueue: [][]ServiceState{ + {{Service: "wordpress", State: "exited", ExitCode: 3}}, + }} + if err := WaitForInit(context.Background(), d, "proj", []string{"wordpress"}, time.Millisecond); err == nil { + t.Fatal("expected error on non-zero init exit") + } +} + +func TestWaitForInitPropagatesPSError(t *testing.T) { + d := &fakeDocker{psErr: errors.New("boom")} + if err := WaitForInit(context.Background(), d, "proj", []string{"wordpress"}, time.Millisecond); err == nil { + t.Fatal("expected ps error to propagate") + } +} diff --git a/internal/devenv/list.go b/internal/devenv/list.go new file mode 100644 index 000000000..200879eff --- /dev/null +++ b/internal/devenv/list.go @@ -0,0 +1,41 @@ +package devenv + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/instancedata" + "github.com/Automattic/vip/internal/devenv/lifecycle" +) + +// EnvStatus is one environment's slug + whether any of its services is running. +type EnvStatus struct { + Slug string + Running bool +} + +// anyRunning reports whether any service is in the "running" state. +func anyRunning(states []lifecycle.ServiceState) bool { + for _, s := range states { + if s.State == "running" { + return true + } + } + return false +} + +// List returns every on-disk environment with its running state. A docker error +// for a single env degrades to Running=false rather than failing the whole list. +func List(ctx context.Context) ([]EnvStatus, error) { + r, err := newRunner(ctx) + if err != nil { + return nil, err + } + d := dockerAdapter{r: r} + var out []EnvStatus + for _, slug := range instancedata.AllNames() { + states, err := d.ComposePS(ctx, slug) + running := err == nil && anyRunning(states) + out = append(out, EnvStatus{Slug: slug, Running: running}) + } + return out, nil +} diff --git a/internal/devenv/list_test.go b/internal/devenv/list_test.go new file mode 100644 index 000000000..00a3f2ddb --- /dev/null +++ b/internal/devenv/list_test.go @@ -0,0 +1,24 @@ +package devenv + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/lifecycle" +) + +func TestAnyRunning(t *testing.T) { + cases := []struct { + name string + states []lifecycle.ServiceState + want bool + }{ + {"none", nil, false}, + {"all-exited", []lifecycle.ServiceState{{Service: "wordpress", State: "exited"}}, false}, + {"one-running", []lifecycle.ServiceState{{Service: "wordpress", State: "exited"}, {Service: "php", State: "running"}}, true}, + } + for _, c := range cases { + if got := anyRunning(c.states); got != c.want { + t.Errorf("%s: anyRunning = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/devenv/logs.go b/internal/devenv/logs.go new file mode 100644 index 000000000..fb93edb9f --- /dev/null +++ b/internal/devenv/logs.go @@ -0,0 +1,41 @@ +package devenv + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/devlog" +) + +// LogOptions controls `dev-env logs`. Node passes timestamps:true always. +type LogOptions struct { + Follow bool + Service string // empty = all services +} + +// logsArgs builds the compose `logs` args (the project + binary are supplied by +// Runner.Compose). Timestamps are always on, matching Node showLogs. +func logsArgs(o LogOptions) []string { + args := []string{"logs", "--timestamps"} + if o.Follow { + args = append(args, "--follow") + } + if o.Service != "" { + args = append(args, o.Service) + } + return args +} + +// Logs streams an env's container logs, tee'd through the unified log. +func Logs(ctx context.Context, slug string, o LogOptions) error { + r, err := newRunner(ctx) + if err != nil { + return err + } + l, err := devlog.Open(slug) + if err != nil { + return err + } + defer l.Close() + r.Log = l + return r.Compose(ctx, slug, logsArgs(o)...) +} diff --git a/internal/devenv/logs_test.go b/internal/devenv/logs_test.go new file mode 100644 index 000000000..a2775f281 --- /dev/null +++ b/internal/devenv/logs_test.go @@ -0,0 +1,28 @@ +package devenv + +import "testing" + +func TestLogsArgs(t *testing.T) { + cases := []struct { + name string + opt LogOptions + want []string + }{ + {"all", LogOptions{}, []string{"logs", "--timestamps"}}, + {"follow", LogOptions{Follow: true}, []string{"logs", "--timestamps", "--follow"}}, + {"service", LogOptions{Service: "database"}, []string{"logs", "--timestamps", "database"}}, + {"follow+service", LogOptions{Follow: true, Service: "php"}, []string{"logs", "--timestamps", "--follow", "php"}}, + } + for _, c := range cases { + got := logsArgs(c.opt) + if len(got) != len(c.want) { + t.Errorf("%s: logsArgs = %v, want %v", c.name, got, c.want) + continue + } + for i := range c.want { + if got[i] != c.want[i] { + t.Errorf("%s: logsArgs[%d] = %q, want %q", c.name, i, got[i], c.want[i]) + } + } + } +} diff --git a/internal/devenv/materialize.go b/internal/devenv/materialize.go new file mode 100644 index 000000000..673445887 --- /dev/null +++ b/internal/devenv/materialize.go @@ -0,0 +1,44 @@ +// Package devenv is the public API the cobra commands call: it materializes an +// environment's compose files, wires the real lifecycle dependencies, and owns +// create + the Start/Stop/Rebuild/Destroy/Purge/Info/Health entry points. +package devenv + +import ( + "os" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// Materialize renders and writes docker-compose.yml, .env, and nginx/extra.conf +// into the env directory, returning that directory. Idempotent (overwrites). +func Materialize(slug string, v compose.View) (string, error) { + dir := paths.EnvironmentPath(slug) + if err := os.MkdirAll(filepath.Join(dir, "nginx"), 0o755); err != nil { + return "", err + } + yml, err := compose.RenderCompose(v) + if err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), yml, 0o644); err != nil { + return "", err + } + // .env is shared with the Node CLI, which stores the user's dev-env + // variables in it. Merge our managed LANDO_HOST_* keys into whatever is + // already there instead of overwriting the file (parity blocker B3) — this + // runs on create, start, rebuild, update and every envvar mutation, so an + // overwrite here silently deleted variables set with the other CLI. + existing, err := readEnvFileRaw(dir) + if err != nil { + return "", err + } + if err := writeEnvFileAtomic(filepath.Join(dir, ".env"), mergeEnvFile(existing, compose.RenderEnvFile(v))); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(dir, "nginx", "extra.conf"), []byte(compose.RenderNginxConf(v)), 0o644); err != nil { + return "", err + } + return dir, nil +} diff --git a/internal/devenv/materialize_test.go b/internal/devenv/materialize_test.go new file mode 100644 index 000000000..c5a77bc7c --- /dev/null +++ b/internal/devenv/materialize_test.go @@ -0,0 +1,112 @@ +package devenv + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +func TestMaterializeWritesFiles(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + v := compose.View{SiteSlug: "example", Domain: "vipdev.lndo.site", HostUID: "1000", HostGID: "1000"} + dir, err := Materialize("example", v) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"docker-compose.yml", ".env", filepath.Join("nginx", "extra.conf")} { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("missing %s: %v", name, err) + } + } + b, _ := os.ReadFile(filepath.Join(dir, "docker-compose.yml")) + if len(b) == 0 { + t.Fatal("empty docker-compose.yml") + } +} + +// Regression for parity blocker B3. Materialize used to os.WriteFile the whole +// of <envdir>/.env with just the two LANDO_HOST_* lines. It runs on create, +// start, rebuild, update and every envvar mutation — and <envdir> is the same +// directory the Node CLI uses (paths.EnvironmentPath is byte-identical to +// Node's getEnvironmentPath), where Node's `dev-env envvar` commands keep user +// variables. So `vip-next dev-env start` silently deleted variables a user had +// set with the Node CLI. Asserting the managed keys are present would not have +// caught this — assert the user's own lines survive. +func TestMaterializePreservesExistingEnvFile(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + v := compose.View{SiteSlug: "example", Domain: "vipdev.site", HostUID: "1000", HostGID: "1000"} + dir, err := Materialize("example", v) + if err != nil { + t.Fatal(err) + } + + // What the Node CLI leaves behind after `vip dev-env envvar set`. + const userContent = "# set by the Node CLI\nMY_TOKEN=\"s3cr3t\"\nOTHER_VAR=\"plain\"\n" + envPath := filepath.Join(dir, ".env") + existing, err := os.ReadFile(envPath) // #nosec G304 + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(envPath, append(existing, []byte(userContent)...), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + // `dev-env start` re-materializes with the same view. + if _, err := Materialize("example", v); err != nil { + t.Fatal(err) + } + + got, err := os.ReadFile(envPath) // #nosec G304 + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`MY_TOKEN="s3cr3t"`, `OTHER_VAR="plain"`, "# set by the Node CLI"} { + if !strings.Contains(string(got), want) { + t.Errorf("re-materialize destroyed %q; .env is now:\n%s", want, got) + } + } + // The managed keys must still be there exactly once — docker compose reads + // them from .env to substitute ${LANDO_HOST_USER_ID} in docker-compose.yml. + if n := strings.Count(string(got), "LANDO_HOST_USER_ID="); n != 1 { + t.Errorf("LANDO_HOST_USER_ID appears %d times, want exactly 1:\n%s", n, got) + } + if n := strings.Count(string(got), "LANDO_HOST_GROUP_ID="); n != 1 { + t.Errorf("LANDO_HOST_GROUP_ID appears %d times, want exactly 1:\n%s", n, got) + } +} + +// A changed host UID must actually take effect — preserving user lines must not +// freeze the managed ones. +func TestMaterializeUpdatesManagedKeysInPlace(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + base := compose.View{SiteSlug: "example", Domain: "vipdev.site", HostUID: "1000", HostGID: "1000"} + dir, err := Materialize("example", base) + if err != nil { + t.Fatal(err) + } + envPath := filepath.Join(dir, ".env") + existing, _ := os.ReadFile(envPath) // #nosec G304 + if err := os.WriteFile(envPath, append(existing, []byte("KEEP=\"me\"\n")...), 0o644); err != nil { // #nosec G306 + t.Fatal(err) + } + + changed := base + changed.HostUID, changed.HostGID = "501", "20" + if _, err := Materialize("example", changed); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(envPath) // #nosec G304 + if !strings.Contains(string(got), "LANDO_HOST_USER_ID=501") || + !strings.Contains(string(got), "LANDO_HOST_GROUP_ID=20") { + t.Errorf("managed keys not updated:\n%s", got) + } + if strings.Contains(string(got), "LANDO_HOST_USER_ID=1000") { + t.Errorf("stale managed value left behind:\n%s", got) + } + if !strings.Contains(string(got), `KEEP="me"`) { + t.Errorf("user line lost:\n%s", got) + } +} diff --git a/internal/devenv/paths/paths.go b/internal/devenv/paths/paths.go new file mode 100644 index 000000000..e794d895a --- /dev/null +++ b/internal/devenv/paths/paths.go @@ -0,0 +1,42 @@ +// Package paths is the single source of truth for vip dev-env on-disk +// locations. Mirrors the Node helpers: xdg-data.ts (xdgData) and +// dev-environment-core.ts (getEnvironmentPath / getAllEnvironmentNames +// base dir). Command logs live per-environment, in a logs/ subdirectory of +// the environment's own instance directory, with one timestamped file per +// invocation (mirroring Node's getDevEnvLogFile). +package paths + +import ( + "os" + "path/filepath" +) + +// XDGData mirrors Node's xdgData(): $XDG_DATA_HOME or ~/.local/share. +func XDGData() string { + if d := os.Getenv("XDG_DATA_HOME"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return filepath.Join(home, ".local", "share") +} + +// DevEnvBase is the directory containing one subdirectory per environment. +// It uses the historical "dev-environment" segment (where existing env data +// already lives). +func DevEnvBase() string { + return filepath.Join(XDGData(), "vip", "dev-environment") +} + +// EnvironmentPath is the directory holding a single environment's state. +func EnvironmentPath(slug string) string { + return filepath.Join(DevEnvBase(), slug) +} + +// EnvLogDir is where an environment's per-invocation command logs live: a +// logs/ subdirectory inside the environment's own instance directory. +func EnvLogDir(slug string) string { + return filepath.Join(EnvironmentPath(slug), "logs") +} diff --git a/internal/devenv/paths/paths_test.go b/internal/devenv/paths/paths_test.go new file mode 100644 index 000000000..31fd890b5 --- /dev/null +++ b/internal/devenv/paths/paths_test.go @@ -0,0 +1,39 @@ +package paths + +import ( + "path/filepath" + "testing" +) + +func TestXDGDataHonorsEnv(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/tmp/xdgcustom") + if got := XDGData(); got != "/tmp/xdgcustom" { + t.Fatalf("XDGData() = %q, want /tmp/xdgcustom", got) + } +} + +func TestXDGDataFallsBackToHome(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + home := t.TempDir() + t.Setenv("HOME", home) + want := filepath.Join(home, ".local", "share") + if got := XDGData(); got != want { + t.Fatalf("XDGData() = %q, want %q", got, want) + } +} + +func TestEnvironmentPath(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + want := filepath.Join("/data", "vip", "dev-environment", "myslug") + if got := EnvironmentPath("myslug"); got != want { + t.Fatalf("EnvironmentPath = %q, want %q", got, want) + } +} + +func TestEnvLogDir(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + want := filepath.Join("/data", "vip", "dev-environment", "myslug", "logs") + if got := EnvLogDir("myslug"); got != want { + t.Fatalf("EnvLogDir = %q, want %q", got, want) + } +} diff --git a/internal/devenv/postimport.go b/internal/devenv/postimport.go new file mode 100644 index 000000000..1c72c2b0d --- /dev/null +++ b/internal/devenv/postimport.go @@ -0,0 +1,134 @@ +package devenv + +import ( + "context" + "fmt" + "io" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +// composeExecer is the subset of dockercli.Runner the post-import steps need. +// Keeping it an interface lets the sequence be unit-tested without Docker. +type composeExecer interface { + Compose(ctx context.Context, project string, args ...string) error +} + +// postImportOptions selects which post-import steps run and how loudly. +type postImportOptions struct { + // Quiet appends --quiet to the wp-cli calls Node passes `quiet` to. + Quiet bool + // SkipReindex skips the Elasticsearch reindex (`--skip-reindex`/-k). + SkipReindex bool +} + +// wpArgs builds `exec -T php wp --allow-root <args...>`. --allow-root is +// required because the Go port's php container runs as root (Lando ran wp as a +// non-root user), so wp-cli would otherwise refuse with "YIKES! running as root". +func wpArgs(args ...string) []string { + return append([]string{"exec", "-T", phpService, "wp", "--allow-root"}, args...) +} + +// flushCacheArgs ports flushCache (dev-environment-database.ts:72-77). +func flushCacheArgs(quiet bool) []string { + args := wpArgs("cache", "flush", "--skip-plugins", "--skip-themes") + if quiet { + args = append(args, "--quiet") + } + return args +} + +// reindexProbeArgs / reindexArgs port reIndexSearch +// (dev-environment-database.ts:60-70): probe for the vip-search command first, +// then run the index. Node runs both inside the same try/catch. +func reindexProbeArgs() []string { return wpArgs("cli", "has-command", "vip-search") } +func reindexArgs() []string { + return wpArgs("vip-search", "index", "--setup", "--network-wide", "--skip-confirm") +} + +// addAdminUserArgs ports addAdminUser (dev-environment-database.ts:23-44). +// NOTE the quiet parameter: Node's import-sql calls addAdminUser(lando, slug) +// with no third argument (dev-env-import-sql.ts:141), so `--quiet` is never +// appended on this path even under `--quiet`/sync. Matched deliberately. +func addAdminUserArgs(password string, quiet bool) []string { + args := wpArgs("dev-env-add-admin", "--username=vipgo", "--password="+password, + "--skip-plugins", "--skip-themes") + if quiet { + args = append(args, "--quiet") + } + return args +} + +// dataCleanupArgs ports dataCleanup (dev-environment-database.ts:46-58). +func dataCleanupArgs(quiet bool) []string { + args := wpArgs("vip", "data-cleanup", "sql-import") + if quiet { + args = append(args, "--quiet") + } + return args +} + +// postImportSteps runs Node's post-import sequence, in Node's order and with +// Node's failure semantics (src/commands/dev-env-import-sql.ts:128-142): +// +// 1. flushCache — uncaught: a failure fails the command. +// 2. reIndexSearch (unless skipped) — try/catch: "Exception means they don't +// have vip-search enabled". +// 3. addAdminUser — uncaught: without it the user is locked out of their own +// wp-admin, so it must be loud. +// 4. dataCleanup — caught: prints "WARNING: data cleanup failed." and continues. +// +// Skipping these is register item 2.20: an imported production dump carries the +// production users table, so the local `vipgo` account vanishes and the user +// cannot log in to their own local wp-admin. +func postImportSteps(ctx context.Context, r composeExecer, slug string, o postImportOptions, out io.Writer) error { + if err := r.Compose(ctx, slug, flushCacheArgs(o.Quiet)...); err != nil { + return err + } + + if !o.SkipReindex { + // Both calls live inside Node's single try/catch; a missing vip-search + // (the common case — Elasticsearch is off by default) is not an error. + if err := r.Compose(ctx, slug, reindexProbeArgs()...); err == nil { + _ = r.Compose(ctx, slug, reindexArgs()...) + } + } + + // quiet=false, not o.Quiet: Node's import path calls addAdminUser with only + // (lando, slug), so the admin step stays verbose even under --quiet and + // under `sync sql` (which sets quiet:true). See addAdminUserArgs. + if err := addAdminUser(ctx, r, slug, false); err != nil { + return err + } + + if err := r.Compose(ctx, slug, dataCleanupArgs(o.Quiet)...); err != nil { + // Node: "This must not be a fatal error". + fmt.Fprintln(out, "WARNING: data cleanup failed.") + } + return nil +} + +// addAdminUser recreates the `vipgo` admin account after an import wiped the +// local users table. It reuses the environment's stored admin password so the +// credentials `dev-env info` prints keep working; a missing password (or the +// placeholder "password") is regenerated and persisted, exactly as Node does. +func addAdminUser(ctx context.Context, r composeExecer, slug string, quiet bool) error { + d, err := instancedata.Read(slug) + if err != nil { + return err + } + password := d.AdminPassword + if password == "" || password == "password" { + password = generatePassword() + } + if err := r.Compose(ctx, slug, addAdminUserArgs(password, quiet)...); err != nil { + return err + } + if password != d.AdminPassword { + d.AdminPassword = password + if err := instancedata.Write(slug, d); err != nil { + return err + } + } + return nil +} diff --git a/internal/devenv/postimport_test.go b/internal/devenv/postimport_test.go new file mode 100644 index 000000000..9cbe37cd1 --- /dev/null +++ b/internal/devenv/postimport_test.go @@ -0,0 +1,389 @@ +package devenv + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +// fakeExecer records every compose invocation and can fail a chosen wp +// subcommand, so the post-import sequence is testable with no Docker. +type fakeExecer struct { + calls [][]string + // failOn fails any call whose joined argv contains this substring. + failOn string +} + +func (f *fakeExecer) Compose(_ context.Context, _ string, args ...string) error { + f.calls = append(f.calls, args) + if f.failOn != "" && strings.Contains(strings.Join(args, " "), f.failOn) { + return errors.New("boom") + } + return nil +} + +func (f *fakeExecer) joined() []string { + out := make([]string, len(f.calls)) + for i, c := range f.calls { + out[i] = strings.Join(c, " ") + } + return out +} + +// Register 2.20. Node's DevEnvImportSQLCommand.run() does NOT stop at the +// `wp db import`: it flushes the object cache, reindexes Elasticsearch, +// (re)creates the `vipgo` admin user and runs the VIP data cleanup +// (src/commands/dev-env-import-sql.ts:128-142). vip-next skipped all of it, +// which is why a user is locked out of their own local wp-admin after an +// import. This pins the steps, their order and their exact argv. +func TestPostImportStepsRunsNodeSequence(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + + f := &fakeExecer{} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &bytes.Buffer{}); err != nil { + t.Fatalf("postImportSteps: %v", err) + } + + want := []string{ + "exec -T php wp --allow-root cache flush --skip-plugins --skip-themes", + "exec -T php wp --allow-root cli has-command vip-search", + "exec -T php wp --allow-root vip-search index --setup --network-wide --skip-confirm", + "exec -T php wp --allow-root dev-env-add-admin --username=vipgo --password=seededpass1 --skip-plugins --skip-themes", + "exec -T php wp --allow-root vip data-cleanup sql-import", + } + got := f.joined() + if len(got) != len(want) { + t.Fatalf("post-import ran %d steps, want %d:\n got %v\nwant %v", len(got), len(want), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("step %d =\n %q\nwant %q", i, got[i], want[i]) + } + } +} + +// Node appends --quiet to flushCache, addAdminUser and dataCleanup from the +// `quiet` argument — except that import-sql calls addAdminUser WITHOUT it +// (dev-env-import-sql.ts:141 passes only lando+slug), so the admin step is +// never quiet. Node's own inconsistency; matched deliberately. +func TestPostImportStepsQuietMatchesNodeArgumentPassing(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + + f := &fakeExecer{} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{Quiet: true}, &bytes.Buffer{}); err != nil { + t.Fatalf("postImportSteps: %v", err) + } + got := f.joined() + want := []string{ + "exec -T php wp --allow-root cache flush --skip-plugins --skip-themes --quiet", + "exec -T php wp --allow-root cli has-command vip-search", + "exec -T php wp --allow-root vip-search index --setup --network-wide --skip-confirm", + "exec -T php wp --allow-root dev-env-add-admin --username=vipgo --password=seededpass1 --skip-plugins --skip-themes", + "exec -T php wp --allow-root vip data-cleanup sql-import --quiet", + } + for i := range want { + if i >= len(got) || got[i] != want[i] { + t.Errorf("quiet step %d = %q, want %q", i, safeIdx(got, i), want[i]) + } + } +} + +func safeIdx(s []string, i int) string { + if i < len(s) { + return s[i] + } + return "<missing>" +} + +// fakeImportRunner is a full stand-in for dockercli.Runner covering everything +// the import path touches, so the whole ImportSQL sequence runs with no Docker. +type fakeImportRunner struct { + fakeExecer + docker [][]string + stdin [][]string + failOnD string + // psStates is what ComposePS returns once psSet is true (psSet exists so a + // test can assert on an EMPTY service list — a never-started environment). + // Unset means php + database running, the normal case. + psStates []dockercli.ServiceState + psSet bool + psErr error +} + +func (f *fakeImportRunner) ComposePS(_ context.Context, _ string) ([]dockercli.ServiceState, error) { + if f.psErr != nil { + return nil, f.psErr + } + if f.psSet { + return f.psStates, nil + } + return []dockercli.ServiceState{ + {Service: "php", State: "running"}, + {Service: "database", State: "running"}, + }, nil +} + +func (f *fakeImportRunner) Docker(_ context.Context, args ...string) error { + f.docker = append(f.docker, args) + if f.failOnD != "" && strings.Contains(strings.Join(args, " "), f.failOnD) { + return errors.New("boom") + } + return nil +} + +func (f *fakeImportRunner) ComposeStdin(_ context.Context, _ string, _ io.Reader, args ...string) error { + f.stdin = append(f.stdin, args) + return nil +} + +func (f *fakeImportRunner) ComposeOut(_ context.Context, _ string, args ...string) ([]byte, error) { + if len(args) > 0 && args[0] == "ps" { + return []byte("containerid123\n"), nil + } + return nil, nil +} + +func seedImportEnv(t *testing.T) { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } +} + +// writeDump produces a dump that passes the dev-env SQL validation (which now +// runs on this path): DROP TABLE + CREATE TABLE + AUTO_INCREMENT present, a +// wp_ prefix, InnoDB, and no siteurl pointing away from the environment. +func writeDump(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "dump.sql") + body := strings.Join([]string{ + "-- MySQL dump 10.13", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + }, "\n") + "\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// Register 2.20 (wiring): the post-import steps must actually run at the end of +// `dev-env import sql`, not merely exist. Before this fix the import stopped at +// `wp db import` and the user was locked out of their local wp-admin. +func TestImportSQLRunsPostImportSteps(t *testing.T) { + seedImportEnv(t) + f := &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", writeDump(t), ImportOptions{}); err != nil { + t.Fatalf("importSQL: %v", err) + } + all := strings.Join(f.joined(), "\n") + for _, want := range []string{ + "wp --allow-root db import", + "cache flush", + "dev-env-add-admin --username=vipgo", + "vip data-cleanup sql-import", + } { + if !strings.Contains(all, want) { + t.Errorf("import sql did not run %q; ran:\n%s", want, all) + } + } + // Order: the import itself must come first. + if i, j := strings.Index(all, "db import"), strings.Index(all, "cache flush"); i < 0 || j < 0 || i > j { + t.Errorf("post-import steps must follow the import, got:\n%s", all) + } +} + +// The MyDumper path shares Node's run(), so it gets the same post-import steps. +// vip-next's importMyDumperDump returned early, skipping all of them. +func TestImportSQLMyDumperRunsPostImportSteps(t *testing.T) { + seedImportEnv(t) + p := filepath.Join(t.TempDir(), "dump.sql") + // A MyDumper stream skips dropTable/dropDB but still has to satisfy the + // createTable / autoIncrement required checks. + body := "-- metadata.header 1\n-- mydb-schema-create.sql 0\n" + + "CREATE TABLE `wp_options` (`option_id` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`option_id`)) ENGINE=InnoDB;\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + f := &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", p, ImportOptions{}); err != nil { + t.Fatalf("importSQL: %v", err) + } + if len(f.stdin) == 0 { + t.Fatal("expected the myloader stream path to be taken") + } + if !strings.Contains(strings.Join(f.joined(), "\n"), "dev-env-add-admin --username=vipgo") { + t.Errorf("MyDumper import skipped the vipgo admin user; ran:\n%v", f.joined()) + } +} + +// Node prints "Success: Database imported." after the import unless --quiet +// (dev-env-import-sql.ts:117). vip-next printed nothing at all. +func TestImportSQLPrintsSuccessUnlessQuiet(t *testing.T) { + seedImportEnv(t) + var out bytes.Buffer + f := &fakeImportRunner{} + if err := importSQL(context.Background(), f, "e", writeDump(t), ImportOptions{Out: &out}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Database imported.") { + t.Errorf("missing Node's success line, got %q", out.String()) + } + + seedImportEnv(t) + out.Reset() + if err := importSQL(context.Background(), &fakeImportRunner{}, "e", writeDump(t), ImportOptions{Quiet: true, Out: &out}); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "Database imported.") { + t.Errorf("--quiet must suppress the success line, got %q", out.String()) + } +} + +// --skip-reindex must actually skip the Elasticsearch reindex (Node: +// dev-env-import-sql.ts:130). It was a documented no-op in vip-next. +func TestPostImportStepsSkipReindex(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + f := &fakeExecer{} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{SkipReindex: true}, &bytes.Buffer{}); err != nil { + t.Fatalf("postImportSteps: %v", err) + } + for _, c := range f.joined() { + if strings.Contains(c, "vip-search") { + t.Errorf("--skip-reindex still ran the reindex: %q", c) + } + } + // The admin user must still be created. + if !strings.Contains(strings.Join(f.joined(), "\n"), "dev-env-add-admin") { + t.Error("--skip-reindex must not skip the vipgo admin user") + } +} + +// Node wraps reIndexSearch in try/catch with the comment "Exception means they +// don't have vip-search enabled" — a missing vip-search must NOT fail the +// import, and must not stop the admin user from being created. +func TestPostImportStepsReindexFailureIsNotFatal(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + f := &fakeExecer{failOn: "vip-search"} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &bytes.Buffer{}); err != nil { + t.Fatalf("a missing vip-search must not fail the import, got %v", err) + } + if !strings.Contains(strings.Join(f.joined(), "\n"), "dev-env-add-admin") { + t.Error("the admin user must still be created after a reindex failure") + } +} + +// Node's dataCleanup catches its own error and prints "WARNING: data cleanup +// failed." (dev-environment-database.ts:53-57) — it must not fail the import. +func TestPostImportStepsDataCleanupFailureWarnsAndContinues(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + f := &fakeExecer{failOn: "data-cleanup"} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &out); err != nil { + t.Fatalf("data cleanup failure must not be fatal, got %v", err) + } + if !strings.Contains(out.String(), "WARNING: data cleanup failed.") { + t.Errorf("missing Node's warning, got %q", out.String()) + } +} + +// addAdminUser is NOT wrapped in Node — a failure there aborts run() and the +// command exits 1. Being locked out must be loud, not silent. +func TestPostImportStepsAdminUserFailureIsFatal(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + f := &fakeExecer{failOn: "dev-env-add-admin"} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &bytes.Buffer{}); err == nil { + t.Error("a failed vipgo admin user must fail the command (Node does not catch it)") + } +} + +// Node's flushCache is also uncaught (dev-env-import-sql.ts:128). +func TestPostImportStepsFlushCacheFailureIsFatal(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: "seededpass1", + }); err != nil { + t.Fatal(err) + } + f := &fakeExecer{failOn: "cache flush"} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &bytes.Buffer{}); err == nil { + t.Error("a failed cache flush must fail the command (Node does not catch it)") + } + if len(f.calls) != 1 { + t.Errorf("flush failure must abort before the later steps, ran %v", f.joined()) + } +} + +// Node's addAdminUser regenerates the password when instance data has none (or +// the placeholder "password") and PERSISTS it, so `dev-env info` shows the +// credentials that actually work (dev-environment-database.ts:23-43). +func TestPostImportStepsGeneratesAndPersistsMissingAdminPassword(t *testing.T) { + for _, seeded := range []string{"", "password"} { + t.Run("seed="+seeded, func(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := instancedata.Write("e", &instancedata.InstanceData{ + SiteSlug: "e", Multisite: []byte("false"), AdminPassword: seeded, + }); err != nil { + t.Fatal(err) + } + f := &fakeExecer{} + if err := postImportSteps(context.Background(), f, "e", postImportOptions{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + d, err := instancedata.Read("e") + if err != nil { + t.Fatal(err) + } + if len(d.AdminPassword) != passwordLength || d.AdminPassword == seeded { + t.Fatalf("adminPassword not regenerated/persisted: %q", d.AdminPassword) + } + if !strings.Contains(strings.Join(f.joined(), "\n"), "--password="+d.AdminPassword) { + t.Errorf("wp was given a different password than the one persisted: %v", f.joined()) + } + }) + } +} diff --git a/internal/devenv/proxy/ca.go b/internal/devenv/proxy/ca.go new file mode 100644 index 000000000..b89a1fcd3 --- /dev/null +++ b/internal/devenv/proxy/ca.go @@ -0,0 +1,103 @@ +package proxy + +import ( + "context" + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +//go:embed scripts/gen-certs.sh +var genCertsScript string + +// ProxyCertsVolume is the shared named volume that holds the CA and per-env +// leaf certs (mounted at /certs on both the cert-gen one-shot and the proxy). +const ProxyCertsVolume = "vip-dev-env-certs" + +// caContainerPath is where the CA PEM lives inside the proxy container. +const caContainerPath = "/certs/lndo.site.pem" + +// CertRequest describes a per-environment leaf cert to generate. +type CertRequest struct { + Basename string // base name for cert files (e.g. "example") + CommonName string // cert subject CN; defaults to Basename if empty + SANs []string // Subject Alternative Names +} + +// validCertField rejects characters that would corrupt the openssl subject or +// the space-split SAN list inside gen-certs.sh. +func validCertField(s string) bool { + return s != "" && !strings.ContainsAny(s, " \t\r\n/") +} + +// CAHostPath returns the host-side path where the CA PEM is extracted to. +func CAHostPath() string { + return filepath.Join(paths.XDGData(), "vip", "dev-env", "proxy", "ca.pem") +} + +// EnsureCA runs a one-shot container that idempotently generates the CA +// (lndo.site.pem / lndo.site.key) in the shared certs volume. +func EnsureCA(ctx context.Context, r DockerRunner) error { + // Intentionally omits proxy_config: no CERT_BASENAME/CERT_SANS are passed, + // so the script's leaf-cert and Traefik YAML section is skipped entirely. + return r.Docker(ctx, + "run", "--rm", + "-v", ProxyCertsVolume+":/certs", + ProxyImage, + "sh", "-c", genCertsScript, + ) +} + +// EnsureCert runs a one-shot container that generates a per-environment leaf +// cert (signed by the CA) and writes a Traefik file-provider YAML into the +// proxy_config volume. Returns an error if Basename or SANs are empty. +func EnsureCert(ctx context.Context, r DockerRunner, req CertRequest) error { + if req.Basename == "" || len(req.SANs) == 0 { + return errors.New("proxy: EnsureCert requires Basename and SANs") + } + if !validCertField(req.Basename) { + return fmt.Errorf("proxy: invalid cert field %q", req.Basename) + } + if req.CommonName != "" && !validCertField(req.CommonName) { + return fmt.Errorf("proxy: invalid cert field %q", req.CommonName) + } + for _, san := range req.SANs { + if !validCertField(san) { + return fmt.Errorf("proxy: invalid cert field %q", san) + } + } + + args := []string{ + "run", "--rm", + "-v", ProxyCertsVolume + ":/certs", + "-v", ProxyConfigVolume + ":/proxy_config", + "-e", "CERT_BASENAME=" + req.Basename, + } + if req.CommonName != "" { + args = append(args, "-e", "CERT_CN="+req.CommonName) + } + args = append(args, + "-e", "CERT_SANS="+strings.Join(req.SANs, " "), + ProxyImage, + "sh", "-c", genCertsScript, + ) + + return r.Docker(ctx, args...) +} + +// ExtractCA docker-cp's the CA PEM from the running proxy container to dest +// on the host. It creates dest's parent directory first and returns dest. +func ExtractCA(ctx context.Context, r DockerRunner, dest string) (string, error) { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + if err := r.Docker(ctx, "cp", ProxyContainerName+":"+caContainerPath, dest); err != nil { + return "", err + } + return dest, nil +} diff --git a/internal/devenv/proxy/ca_test.go b/internal/devenv/proxy/ca_test.go new file mode 100644 index 000000000..26ac36f6b --- /dev/null +++ b/internal/devenv/proxy/ca_test.go @@ -0,0 +1,111 @@ +package proxy + +import ( + "context" + "strings" + "testing" +) + +func TestProxyRunArgsMountsCertsVolume(t *testing.T) { + joined := strings.Join(proxyRunArgs(Ports{HTTP: 80, HTTPS: 443}, "vipdev.lndo.site"), " ") + if !strings.Contains(joined, ProxyCertsVolume+":/certs") { + t.Fatalf("proxy run args missing certs volume mount:\n%s", joined) + } + if strings.Contains(joined, "TODO") { + t.Fatalf("TODO placeholder still present:\n%s", joined) + } +} + +func TestCAHostPathUnderXDG(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + if got := CAHostPath(); got != "/data/vip/dev-env/proxy/ca.pem" { + t.Fatalf("CAHostPath = %q", got) + } +} + +func TestEnsureCARunsGenScript(t *testing.T) { + r := &fakeRunner{} + if err := EnsureCA(context.Background(), r); err != nil { + t.Fatal(err) + } + if len(r.calls) != 1 { + t.Fatalf("expected 1 docker call, got %d: %v", len(r.calls), r.calls) + } + joined := strings.Join(r.calls[0], " ") + for _, want := range []string{"run", "--rm", ProxyCertsVolume + ":/certs", ProxyImage, "sh", "-c"} { + if !strings.Contains(joined, want) { + t.Fatalf("EnsureCA call missing %q:\n%s", want, joined) + } + } + // the embedded script must actually generate the CA + last := r.calls[0][len(r.calls[0])-1] + if !strings.Contains(last, "lndo.site.pem") { + t.Fatalf("gen script not passed as final arg:\n%s", last) + } +} + +func TestEnsureCertBuildsEnvAndMounts(t *testing.T) { + r := &fakeRunner{} + err := EnsureCert(context.Background(), r, CertRequest{ + Basename: "example", + CommonName: "example.vipdev.lndo.site", + SANs: []string{"example.vipdev.lndo.site", "*.vipdev.lndo.site", "localhost"}, + }) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(r.calls[0], " ") + for _, want := range []string{ + "proxy_config:/proxy_config", + "CERT_BASENAME=example", + "CERT_CN=example.vipdev.lndo.site", + "CERT_SANS=example.vipdev.lndo.site *.vipdev.lndo.site localhost", + } { + if !strings.Contains(joined, want) { + t.Fatalf("EnsureCert call missing %q:\n%s", want, joined) + } + } +} + +func TestEnsureCertRejectsEmpty(t *testing.T) { + r := &fakeRunner{} + if err := EnsureCert(context.Background(), r, CertRequest{Basename: "x"}); err == nil { + t.Fatal("expected error when SANs empty") + } +} + +func TestEnsureCertRejectsEmptyBasename(t *testing.T) { + r := &fakeRunner{} + if err := EnsureCert(context.Background(), r, CertRequest{SANs: []string{"foo.test"}}); err == nil { + t.Fatal("expected error when Basename empty") + } +} + +func TestEnsureCertRejectsBadChars(t *testing.T) { + r := &fakeRunner{} + cases := []CertRequest{ + {Basename: "a/b", SANs: []string{"foo.test"}}, + {Basename: "ok", CommonName: "bad/cn", SANs: []string{"foo.test"}}, + {Basename: "ok", SANs: []string{"foo bar"}}, + } + for i, c := range cases { + if err := EnsureCert(context.Background(), r, c); err == nil { + t.Fatalf("case %d: expected validation error for %+v", i, c) + } + } +} + +func TestExtractCACopiesFromProxy(t *testing.T) { + r := &fakeRunner{} + dest, err := ExtractCA(context.Background(), r, t.TempDir()+"/ca.pem") + if err != nil { + t.Fatal(err) + } + if dest == "" { + t.Fatal("ExtractCA should return the dest path") + } + joined := strings.Join(r.calls[0], " ") + if !strings.Contains(joined, "cp") || !strings.Contains(joined, ProxyContainerName+":"+caContainerPath) { + t.Fatalf("ExtractCA should docker cp the CA from the proxy:\n%s", joined) + } +} diff --git a/internal/devenv/proxy/network.go b/internal/devenv/proxy/network.go new file mode 100644 index 000000000..f633ad6fd --- /dev/null +++ b/internal/devenv/proxy/network.go @@ -0,0 +1,24 @@ +package proxy + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// DockerRunner is the slice of dockercli.Runner this package needs. The +// concrete *dockercli.Runner satisfies it (Docker(ctx, args...) error). +type DockerRunner interface { + Docker(ctx context.Context, args ...string) error +} + +// EnsureNetwork creates the shared external bridge network if it is absent. +// Ports Lando's bridge-network role onto our single shared network +// (compose.ProxyNetwork). docker network inspect returns non-zero when the +// network does not exist; we then create it. +func EnsureNetwork(ctx context.Context, r DockerRunner) error { + if err := r.Docker(ctx, "network", "inspect", compose.ProxyNetwork); err == nil { + return nil + } + return r.Docker(ctx, "network", "create", "--driver", "bridge", compose.ProxyNetwork) +} diff --git a/internal/devenv/proxy/network_test.go b/internal/devenv/proxy/network_test.go new file mode 100644 index 000000000..af0ad785b --- /dev/null +++ b/internal/devenv/proxy/network_test.go @@ -0,0 +1,62 @@ +package proxy + +import ( + "context" + "strings" + "testing" +) + +func TestPortsStatePathUnderXDG(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + want := "/data/vip/dev-env/proxy-ports.json" + if got := PortsStatePath(); got != want { + t.Fatalf("PortsStatePath = %q, want %q", got, want) + } +} + +// fakeRunner records docker invocations and lets a test script their outcome. +type fakeRunner struct { + calls [][]string + failSub string // if a docker arg list contains this, Docker returns err +} + +func (f *fakeRunner) Docker(ctx context.Context, args ...string) error { + f.calls = append(f.calls, args) + if f.failSub != "" { + for _, a := range args { + if strings.Contains(a, f.failSub) { + return errDocker + } + } + } + return nil +} + +func TestEnsureNetworkCreatesWhenMissing(t *testing.T) { + // network inspect fails (missing) -> network create is issued. + r := &fakeRunner{failSub: "inspect"} + if err := EnsureNetwork(context.Background(), r); err != nil { + t.Fatalf("EnsureNetwork: %v", err) + } + var sawCreate bool + for _, c := range r.calls { + if len(c) >= 2 && c[0] == "network" && c[1] == "create" { + sawCreate = true + } + } + if !sawCreate { + t.Fatalf("expected network create, calls=%v", r.calls) + } +} + +func TestEnsureNetworkNoopWhenPresent(t *testing.T) { + r := &fakeRunner{} // inspect succeeds -> no create + if err := EnsureNetwork(context.Background(), r); err != nil { + t.Fatal(err) + } + for _, c := range r.calls { + if len(c) >= 2 && c[0] == "network" && c[1] == "create" { + t.Fatalf("should not create when network exists: %v", r.calls) + } + } +} diff --git a/internal/devenv/proxy/ports.go b/internal/devenv/proxy/ports.go new file mode 100644 index 000000000..42db38b7a --- /dev/null +++ b/internal/devenv/proxy/ports.go @@ -0,0 +1,104 @@ +// Package proxy manages the shared Traefik reverse proxy (the traefik_openssl +// image), the shared bridge network, fallback-port selection, and the local CA +// + per-environment edge certificates for vip dev environments (spec §5/§8). +// It drives docker through a DockerRunner (the concrete *dockercli.Runner in +// production). Plan 4 lifecycle entry points: Ensure (start/ensure proxy with +// bind-retry fallback ports), RemoveOrphan, Cleanup, EnsureCA, EnsureCert (one +// central leaf cert per env, SANs from compose.CertSANs — the image runs no +// in-service cert machinery, per Task 1 findings), ExtractCA (CA PEM to the host +// for trust), and CAHostPath. +package proxy + +import ( + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strconv" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// PortsStatePath is where the chosen proxy ports are persisted. +func PortsStatePath() string { + return filepath.Join(paths.XDGData(), "vip", "dev-env", "proxy-ports.json") +} + +// Default ports + fallbacks (parity with lando-proxy/index.js). +const ( + DefaultHTTP = 80 + DefaultHTTPS = 443 + ProxyBindAddress = "127.0.0.1" +) + +var ( + HTTPFallbacks = []int{8000, 8080, 8888, 8008} + HTTPSFallbacks = []int{444, 4433, 4444, 4443} +) + +// Ports holds the host ports the proxy is bound to. +type Ports struct { + HTTP int `json:"http"` + HTTPS int `json:"https"` +} + +// SelectPort returns the preferred port if free, else the first free fallback. +// free reports whether a port is bindable; production passes a net.Listen probe. +// The caller (proxy.Ensure) re-validates via the actual Docker bind. +func SelectPort(preferred int, fallbacks []int, free func(int) bool) (int, error) { + if free(preferred) { + return preferred, nil + } + for _, p := range fallbacks { + if free(p) { + return p, nil + } + } + return 0, errors.New("proxy: no free port among preferred + fallbacks") +} + +// ListenProbe is the production free-port oracle. For privileged ports (<1024) +// it is OPTIMISTIC: a non-root process cannot net.Listen on them, but the +// Docker daemon can bind them, so we defer to the actual Docker bind (and the +// Ensure bind-retry) as the source of truth (spec §8). For >=1024 it does a +// real TCP listen on the proxy bind address. +func ListenProbe(port int) bool { + if port < 1024 { + return true + } + ln, err := net.Listen("tcp", net.JoinHostPort(ProxyBindAddress, strconv.Itoa(port))) + if err != nil { + return false + } + _ = ln.Close() + return true +} + +// SavePorts persists the chosen ports. +func SavePorts(path string, p Ports) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.Marshal(p) + if err != nil { + return err + } + return os.WriteFile(path, b, 0o644) +} + +// LoadPorts reads persisted ports; a missing file yields zero Ports, no error. +func LoadPorts(path string) (Ports, error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Ports{}, nil + } + if err != nil { + return Ports{}, err + } + var p Ports + if err := json.Unmarshal(b, &p); err != nil { + return Ports{}, err + } + return p, nil +} diff --git a/internal/devenv/proxy/ports_test.go b/internal/devenv/proxy/ports_test.go new file mode 100644 index 000000000..242b083f4 --- /dev/null +++ b/internal/devenv/proxy/ports_test.go @@ -0,0 +1,76 @@ +package proxy + +import ( + "path/filepath" + "testing" +) + +func TestSelectPortPrefersFirstFree(t *testing.T) { + free := func(p int) bool { return p != 80 } // 80 busy, 8000 free + got, err := SelectPort(80, []int{8000, 8080}, free) + if err != nil { + t.Fatal(err) + } + if got != 8000 { + t.Fatalf("got %d, want 8000 (80 busy)", got) + } +} + +func TestSelectPortPreferredWhenFree(t *testing.T) { + got, err := SelectPort(443, []int{444}, func(int) bool { return true }) + if err != nil { + t.Fatal(err) + } + if got != 443 { + t.Fatalf("got %d, want 443", got) + } +} + +func TestSelectPortAllBusy(t *testing.T) { + _, err := SelectPort(80, []int{8000}, func(int) bool { return false }) + if err == nil { + t.Fatal("expected error when all candidates busy") + } +} + +func TestPortsPersistRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "proxy-ports.json") + if err := SavePorts(path, Ports{HTTP: 8080, HTTPS: 4433}); err != nil { + t.Fatal(err) + } + got, err := LoadPorts(path) + if err != nil { + t.Fatal(err) + } + if got.HTTP != 8080 || got.HTTPS != 4433 { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +func TestLoadPortsMissingReturnsZero(t *testing.T) { + got, err := LoadPorts(filepath.Join(t.TempDir(), "nope.json")) + if err != nil { + t.Fatalf("missing file should not error: %v", err) + } + if got.HTTP != 0 || got.HTTPS != 0 { + t.Fatalf("missing file should yield zero Ports, got %+v", got) + } +} + +func TestSelectPortNoFallbacksAllBusy(t *testing.T) { + if _, err := SelectPort(80, nil, func(int) bool { return false }); err == nil { + t.Fatal("expected error with no fallbacks and preferred busy") + } +} + +func TestListenProbeOptimisticForPrivilegedPorts(t *testing.T) { + // Ports <1024 are reported free (let Docker + retry decide), regardless of + // whether this non-root test process could bind them. + if !ListenProbe(80) { + t.Fatal("ListenProbe(80) should be optimistic (privileged port)") + } + if !ListenProbe(443) { + t.Fatal("ListenProbe(443) should be optimistic (privileged port)") + } +} diff --git a/internal/devenv/proxy/proxy.go b/internal/devenv/proxy/proxy.go new file mode 100644 index 000000000..5f1aa7ebc --- /dev/null +++ b/internal/devenv/proxy/proxy.go @@ -0,0 +1,114 @@ +package proxy + +import ( + "context" + "errors" + "fmt" +) + +// EnsureOptions configures an Ensure call. Domain is used in Traefik env vars. +// free is an unexported probe so in-package tests can inject a stub; when nil, +// production falls back to ListenProbe. +type EnsureOptions struct { + Domain string + free func(int) bool +} + +// IsRunning reports whether the proxy container is currently running by +// inspecting it with `docker inspect`. Because DockerRunner only surfaces an +// error (no stdout), we treat "inspect succeeds" as "container exists and is +// running". A *stopped* orphan container also passes this check, so callers +// that want to replace a stopped proxy should invoke RemoveOrphan first. +func IsRunning(ctx context.Context, r DockerRunner) bool { + err := r.Docker(ctx, "inspect", "-f", "{{.State.Running}}", ProxyContainerName) + return err == nil +} + +// Ensure starts the shared Traefik proxy if it is not already running, +// selecting free host ports and persisting them. On a Docker-level bind +// failure (TOCTOU race between ListenProbe and the actual bind) it cleans up +// the partial container and retries with the next HTTP candidate. HTTPS stays +// fixed at the first candidate that passed the probe — advancing both +// independently would require per-entrypoint error attribution from Docker +// stderr, which the error-only DockerRunner does not expose; this is a +// documented follow-up. +func Ensure(ctx context.Context, r DockerRunner, opts EnsureOptions) (Ports, error) { + free := opts.free + if free == nil { + free = ListenProbe + } + + if IsRunning(ctx, r) { + // Already running — load whatever ports were persisted last time. + // A missing state file yields zero Ports (no error); the caller treats + // that as "ports unknown". + ports, err := LoadPorts(PortsStatePath()) + return ports, err + } + + if err := EnsureNetwork(ctx, r); err != nil { + return Ports{}, err + } + + // Choose HTTPS once; advance HTTP candidate on each bind failure. + httpsPort, err := SelectPort(DefaultHTTPS, HTTPSFallbacks, free) + if err != nil { + return Ports{}, err + } + + httpCandidates := append([]int{DefaultHTTP}, HTTPFallbacks...) + + var lastErr error + for _, hp := range httpCandidates { + if !free(hp) { + continue + } + ports := Ports{HTTP: hp, HTTPS: httpsPort} + if err := r.Docker(ctx, proxyRunArgs(ports, opts.Domain)...); err != nil { + lastErr = err + // Clean up the name-collision / partial container before retrying. + _ = r.Docker(ctx, "rm", "-f", ProxyContainerName) + continue + } + if err := SavePorts(PortsStatePath(), ports); err != nil { + return Ports{}, err + } + return ports, nil + } + + if lastErr != nil { + return Ports{}, fmt.Errorf("proxy: could not start after trying http candidates: %w", lastErr) + } + return Ports{}, errors.New("proxy: no free http port among preferred + fallbacks") +} + +// RemoveOrphan best-effort removes a *stopped* proxy container so a fresh one +// can bind (ports Node ensureNoOrphantProxyContainer). It uses `docker rm` +// WITHOUT -f: that removes a stopped container but errors on a running one, +// leaving a healthy proxy intact. All errors are intentionally ignored — the +// expected "cannot remove a running container" error is indistinguishable from +// a real daemon failure through the error-only runner, so we never surface it. +func RemoveOrphan(ctx context.Context, r DockerRunner) error { + _ = r.Docker(ctx, "rm", ProxyContainerName) + return nil +} + +// ForceRemove force-removes the shared proxy container (running or stopped) so a +// subsequent Ensure rebuilds it from the correct image. Used by Lando adoption: +// Lando's proxy shares the vip-dev-env-proxy name, and IsRunning would otherwise +// treat it as ours and skip recreation. +func ForceRemove(ctx context.Context, r DockerRunner) error { + return r.Docker(ctx, "rm", "-f", ProxyContainerName) +} + +// Cleanup removes the proxy container and its proxy_config volume when no dev +// environment remains. All errors are ignored (best-effort). The caller +// decides when no environments remain. +func Cleanup(ctx context.Context, r DockerRunner) error { + _ = r.Docker(ctx, "rm", "-f", ProxyContainerName) + // ProxyCertsVolume (the CA + leaf certs) is intentionally NOT removed: the CA + // survives teardown so the user need not re-trust it on the next create, which + // would otherwise cost an admin privilege prompt. + _ = r.Docker(ctx, "volume", "rm", ProxyConfigVolume) + return nil +} diff --git a/internal/devenv/proxy/proxy_test.go b/internal/devenv/proxy/proxy_test.go new file mode 100644 index 000000000..3f38cf6be --- /dev/null +++ b/internal/devenv/proxy/proxy_test.go @@ -0,0 +1,94 @@ +package proxy + +import ( + "context" + "testing" +) + +// scriptRunner returns scripted results per docker subcommand for control-flow +// tests. runErrs is consumed in order for each `run` call (nil = success). +type scriptRunner struct { + running bool // result for IsRunning's inspect + runErrs []error // sequential results for `run` calls + calls [][]string +} + +func (s *scriptRunner) Docker(ctx context.Context, args ...string) error { + s.calls = append(s.calls, args) + switch { + case len(args) > 0 && args[0] == "run": + if len(s.runErrs) > 0 { + e := s.runErrs[0] + s.runErrs = s.runErrs[1:] + return e + } + return nil + case len(args) >= 2 && args[0] == "inspect": + if s.running { + return nil + } + return errDocker + } + return nil +} + +func TestEnsureRunsProxyAndPersistsPorts(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + r := &scriptRunner{running: false} + free := func(int) bool { return true } // 80/443 free per probe + got, err := Ensure(context.Background(), r, EnsureOptions{Domain: "vipdev.lndo.site", free: free}) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + if got.HTTP != 80 || got.HTTPS != 443 { + t.Fatalf("expected default ports, got %+v", got) + } + persisted, _ := LoadPorts(PortsStatePath()) + if persisted != got { + t.Fatalf("ports not persisted: %+v vs %+v", persisted, got) + } +} + +func TestEnsureNoopWhenAlreadyRunning(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if err := SavePorts(PortsStatePath(), Ports{HTTP: 8080, HTTPS: 4433}); err != nil { + t.Fatal(err) + } + r := &scriptRunner{running: true} + got, err := Ensure(context.Background(), r, EnsureOptions{Domain: "vipdev.lndo.site", free: func(int) bool { return true }}) + if err != nil { + t.Fatal(err) + } + if got.HTTP != 8080 || got.HTTPS != 4433 { + t.Fatalf("already-running path should return persisted ports, got %+v", got) + } + for _, c := range r.calls { + if len(c) > 0 && c[0] == "run" { + t.Fatalf("should not run proxy when already running: %v", r.calls) + } + } +} + +func TestEnsureRetriesNextPortOnBindFailure(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + // First run fails (port busy at bind time despite probe), second succeeds. + r := &scriptRunner{running: false, runErrs: []error{errDocker, nil}} + got, err := Ensure(context.Background(), r, EnsureOptions{Domain: "vipdev.lndo.site", free: func(int) bool { return true }}) + if err != nil { + t.Fatalf("Ensure: %v", err) + } + // After the http bind failed once, the next http candidate (8000) is used. + if got.HTTP != 8000 { + t.Fatalf("expected retry to 8000 after bind failure, got %+v", got) + } + // the failed name collision is cleaned up before retry + var sawRm bool + for _, c := range r.calls { + if len(c) > 0 && c[0] == "rm" { + sawRm = true + } + } + if !sawRm { + t.Fatalf("expected rm of partial proxy before retry: %v", r.calls) + } +} diff --git a/internal/devenv/proxy/scripts/gen-certs.sh b/internal/devenv/proxy/scripts/gen-certs.sh new file mode 100644 index 000000000..c7a50288b --- /dev/null +++ b/internal/devenv/proxy/scripts/gen-certs.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +# Shared CA paths inside the certs volume. +CA_CERT="${CA_CERT:-/certs/lndo.site.pem}" +CA_KEY="${CA_KEY:-/certs/lndo.site.key}" + +# 1. Ensure the CA exists (idempotent). Subject CN parity: WPVIP Local CA. +if [ ! -f "$CA_KEY" ]; then + openssl genrsa -out "$CA_KEY" 2048 +fi +if [ ! -f "$CA_CERT" ]; then + openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 8675 \ + -out "$CA_CERT" \ + -subj "/C=US/ST=California/L=San Francisco/O=Automattic/OU=WPVIP/CN=WPVIP Local CA" +fi + +# 2. Per-env leaf cert + Traefik file-provider config (only when requested). +# CERT_BASENAME names the cert files; CERT_SANS is a space-separated host list; +# CERT_CN is the cert subject CN (defaults to CERT_BASENAME). +if [ -n "$CERT_BASENAME" ] && [ -n "$CERT_SANS" ]; then + CRT="/certs/${CERT_BASENAME}.crt" + KEY="/certs/${CERT_BASENAME}.key" + CSR="/certs/${CERT_BASENAME}.csr" + EXT="/certs/${CERT_BASENAME}.ext" + CN="${CERT_CN:-$CERT_BASENAME}" + + { + printf '%s\n' "authorityKeyIdentifier=keyid,issuer" + printf '%s\n' "basicConstraints=CA:FALSE" + printf '%s\n' "keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment" + printf '%s\n' "extendedKeyUsage = serverAuth" + printf '%s\n' "subjectAltName = @alt_names" + printf '%s\n' "[alt_names]" + } > "$EXT" + set -f + i=1 + for san in $CERT_SANS; do + printf 'DNS.%s = %s\n' "$i" "$san" >> "$EXT" + i=$((i + 1)) + done + set +f + + openssl genrsa -out "$KEY" 2048 + openssl req -new -key "$KEY" -out "$CSR" \ + -subj "/C=US/ST=California/L=San Francisco/O=Automattic/OU=WPVIP/CN=${CN}" + openssl x509 -req -in "$CSR" -CA "$CA_CERT" -CAkey "$CA_KEY" \ + -CAcreateserial -out "$CRT" -days 825 -sha256 -extfile "$EXT" + rm -f "$CSR" "$EXT" + + mkdir -p /proxy_config + cat > "/proxy_config/${CERT_BASENAME}.yaml" <<EOF +tls: + certificates: + - certFile: ${CRT} + keyFile: ${KEY} +EOF +fi diff --git a/internal/devenv/proxy/spec.go b/internal/devenv/proxy/spec.go new file mode 100644 index 000000000..b7f4a8597 --- /dev/null +++ b/internal/devenv/proxy/spec.go @@ -0,0 +1,60 @@ +package proxy + +import ( + "fmt" + + "github.com/Automattic/vip/internal/devenv/compose" +) + +// ProxyContainerName is the shared proxy container (parity: dev-environment-lando.ts:330). +const ProxyContainerName = "vip-dev-env-proxy" + +// ProxyImage is the Traefik+openssl image (parity: lando-proxy builder.js). +const ProxyImage = "ghcr.io/automattic/vip-container-images/traefik_openssl:v3" + +// ProxyConfigVolume holds the Traefik file-provider configs (per-env tls certs). +const ProxyConfigVolume = "proxy_config" + +// proxyCommand is the Traefik arg list (parity: lando-proxy/index.js) plus the +// docker.network arg so Traefik resolves services on our single shared network. +var proxyCommand = []string{ + "--log.level=DEBUG", + // API enabled (no auth) for health checks on the ephemeral :8080 bind; UI off. + "--api.insecure=true", + "--api.dashboard=false", + "--providers.docker=true", + "--providers.docker.network=" + compose.ProxyNetwork, + "--providers.docker.exposedbydefault=false", + "--entrypoints.https.address=:443", + "--entrypoints.http.address=:80", + "--providers.file.directory=/proxy_config", + "--providers.file.watch=true", +} + +// proxyRunArgs builds the `docker run` argv for the shared proxy. The cert +// volume + boot-script mounts are appended per Task 1 findings (see Task 7); +// this builder establishes the network, ports, socket mount, env, and command. +func proxyRunArgs(ports Ports, domain string) []string { + args := []string{ + "run", "-d", + "--name", ProxyContainerName, + "--network", compose.ProxyNetwork, + "--restart", "unless-stopped", + "-p", fmt.Sprintf("%s:%d:80", ProxyBindAddress, ports.HTTP), + "-p", fmt.Sprintf("%s:%d:443", ProxyBindAddress, ports.HTTPS), + "-p", fmt.Sprintf("%s::8080", ProxyBindAddress), + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", ProxyConfigVolume + ":/proxy_config", + "-v", ProxyCertsVolume + ":/certs", + // The four LANDO_* vars below are retained for Lando image parity but are + // inert under the stock Traefik entrypoint: the image bundles none of + // Lando's cert scripts that would consume them. + "-e", "LANDO_APP_PROJECT=_lando_", + "-e", fmt.Sprintf("LANDO_EXTRA_NAMES=DNS.100 = *.%s", domain), + "-e", "LANDO_PROXY_CONFIG_FILE=/proxy_config/proxy.yaml", + "-e", "LANDO_PROXY_PASSTHRU=true", + ProxyImage, + } + args = append(args, proxyCommand...) + return args +} diff --git a/internal/devenv/proxy/spec_test.go b/internal/devenv/proxy/spec_test.go new file mode 100644 index 000000000..1415b7fef --- /dev/null +++ b/internal/devenv/proxy/spec_test.go @@ -0,0 +1,44 @@ +package proxy + +import ( + "strings" + "testing" +) + +func TestProxyRunArgs(t *testing.T) { + args := proxyRunArgs(Ports{HTTP: 8080, HTTPS: 4433}, "vipdev.lndo.site") + joined := strings.Join(args, " ") + + for _, want := range []string{ + "run", "-d", + "--name " + ProxyContainerName, + "--network vip-dev-env", + ProxyImage, + "--providers.docker=true", + "--providers.docker.network=vip-dev-env", + "--entrypoints.http.address=:80", + "--entrypoints.https.address=:443", + } { + if !strings.Contains(joined, want) { + t.Fatalf("run args missing %q:\n%s", want, joined) + } + } + // host port bindings: Ports.HTTP -> container :80, Ports.HTTPS -> container :443 + if !strings.Contains(joined, "-p 127.0.0.1:8080:80") { + t.Fatalf("missing http port binding: %s", joined) + } + if !strings.Contains(joined, "-p 127.0.0.1:4433:443") { + t.Fatalf("missing https port binding: %s", joined) + } + // ephemeral host port for the Traefik API/dashboard + if !strings.Contains(joined, "127.0.0.1::8080") { + t.Fatalf("missing dashboard port binding: %s", joined) + } + if !strings.Contains(joined, "/var/run/docker.sock:/var/run/docker.sock") { + t.Fatalf("missing docker.sock mount: %s", joined) + } + // wildcard SAN env for the domain + if !strings.Contains(joined, "LANDO_EXTRA_NAMES=DNS.100 = *.vipdev.lndo.site") { + t.Fatalf("missing wildcard extra-names env: %s", joined) + } +} diff --git a/internal/devenv/proxy/testhelpers_test.go b/internal/devenv/proxy/testhelpers_test.go new file mode 100644 index 000000000..56ed909bd --- /dev/null +++ b/internal/devenv/proxy/testhelpers_test.go @@ -0,0 +1,7 @@ +package proxy + +import "errors" + +// errDocker is a sentinel a fake DockerRunner returns to signal command +// failure in tests; production runners return the real exec error. +var errDocker = errors.New("docker command failed") diff --git a/internal/devenv/proxy_policy_test.go b/internal/devenv/proxy_policy_test.go new file mode 100644 index 000000000..29a168f78 --- /dev/null +++ b/internal/devenv/proxy_policy_test.go @@ -0,0 +1,155 @@ +package devenv + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + xproxy "golang.org/x/net/http/httpproxy" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// proxyPolicyEnv is every variable internal/httpproxy consults. Cleared so an +// ambient shell (or `make test-parity-unit-hostile`, which exports all of them) +// cannot decide the outcome. +var proxyPolicyEnv = []string{ + "VIP_PROXY", "vip_proxy", + "SOCKS_PROXY", "socks_proxy", + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + "VIP_USE_SYSTEM_PROXY", + "npm_config_proxy", "npm_config_https_proxy", "npm_config_http_proxy", "npm_config_no_proxy", +} + +func clearProxyPolicyEnv(t *testing.T) { + t.Helper() + for _, k := range proxyPolicyEnv { + t.Setenv(k, "") + } +} + +func deadLoopbackAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + return addr +} + +// assertStdlibWouldNotProxy is the guard against a vacuous pass. Neither +// net/http's resolver nor x/net's will ever proxy a loopback host, so a test +// that only asserted "the request failed" could be passing for a reason that +// has nothing to do with the fix. Pinning that the stdlib resolver declines +// this target makes the contrast explicit: the OLD code reached the server. +func assertStdlibWouldNotProxy(t *testing.T, rawURL string) { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse %q: %v", rawURL, err) + } + got, err := xproxy.FromEnvironment().ProxyFunc()(u) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if got != nil { + t.Fatalf("precondition failed: the stdlib resolver picked %s for %s, so this test could "+ + "pass without the fix", got, rawURL) + } +} + +// TestRegistryReachableHonoursVIPProxy pins the ghcr.io reachability probe to +// vip-next's proxy policy. +// +// The probe gates image pulls (lifecycle.ShouldPull). On http.DefaultTransport +// it ignored VIP_PROXY and SOCKS_PROXY entirely, so a developer behind the VIP +// SOCKS proxy — whose only route off the machine IS that proxy — got a direct +// HEAD that could only succeed by accident, or an ambient HTTPS_PROXY honoured +// without the VIP_USE_SYSTEM_PROXY opt-in. +func TestRegistryReachableHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + clearProxyPolicyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+deadLoopbackAddr(t)) + assertStdlibWouldNotProxy(t, srv.URL) + + restore := registryProbeURL + registryProbeURL = srv.URL + defer func() { registryProbeURL = restore }() + + if registryReachable() { + t.Fatal("registryReachable() = true; VIP_PROXY was ignored and the probe went direct") + } +} + +// TestRegistryReachableSucceedsWithoutAProxy is the other half: the fix must +// not make every developer look offline. +func TestRegistryReachableSucceedsWithoutAProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + clearProxyPolicyEnv(t) + + restore := registryProbeURL + registryProbeURL = srv.URL + defer func() { registryProbeURL = restore }() + + if !registryReachable() { + t.Fatal("registryReachable() = false with no proxy configured") + } +} + +// TestHealthProbeIsNeverProxied is the deliberate exception, and the reason +// this slice did not simply route every dev-environment request through the +// policy. +// +// httpProber fetches https://<slug>.vipdev.site/ — a name /etc/hosts maps to +// 127.0.0.1 on the developer's own machine. ProxyURL applies VIP_PROXY +// unconditionally with no loopback exemption (deliberately, to match Node's +// proxy-from-env), so wiring this probe to httpproxy.Client would break every +// developer with the VIP SOCKS proxy exported: the proxy would resolve and dial +// the env's hostname on ITS side, where the containers do not exist. Node does +// not proxy it either — the only dev-environment request Node hands to +// createProxyAgent is the WordPress version manifest. +// +// Both clients run against the same server under the same environment, so the +// assertion cannot pass vacuously: if the policy client ever stops failing +// here, the prober's success proves nothing and this test says so. +func TestHealthProbeIsNeverProxied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + clearProxyPolicyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+deadLoopbackAddr(t)) + + if resp, err := httpproxy.ClientWithTimeout(5 * time.Second).Get(srv.URL); err == nil { + _ = resp.Body.Close() + t.Fatal("precondition failed: the policy client reached a loopback target, so this test " + + "can no longer tell a direct probe apart from a proxied one") + } + + code, err := httpProber{}.Probe(srv.URL) + if err != nil { + t.Fatalf("health probe was routed through VIP_PROXY: %v", err) + } + if code != http.StatusOK { + t.Fatalf("health probe status = %d, want 200", code) + } +} diff --git a/internal/devenv/syncplan.go b/internal/devenv/syncplan.go new file mode 100644 index 000000000..61b5e489c --- /dev/null +++ b/internal/devenv/syncplan.go @@ -0,0 +1,573 @@ +package devenv + +import ( + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "golang.org/x/net/idna" +) + +// SyncSite is the SDS metadata needed to plan one network site's URL and +// wp_blogs domain rewrites. +type SyncSite struct { + BlogID int64 + HomeURL string + SiteURL string +} + +type MappingOrigin string + +const ( + MappingSDS MappingOrigin = "sds" + MappingOverride MappingOrigin = "override" + MappingRecovery MappingOrigin = "recovery" +) + +type URLMapping struct { + Source string + Target string + BlogID int64 + Origin MappingOrigin +} + +type DomainRepair struct { + BlogID int64 + SourceDomain string + TargetDomain string +} + +type UnresolvedMapping struct { + Source string + Reason string +} + +type PlanInput struct { + IsMultisite bool + BaseHost string + ActiveURLs []string + Sites []SyncSite + Overrides []string + Recoveries []string + CatalogIssue string +} + +type SyncPlan struct { + SearchReplace []URLMapping + DomainRepairs []DomainRepair + RequiredHosts []string +} + +type PlanDraft struct { + Plan SyncPlan + Unresolved []UnresolvedMapping +} + +type normalizedSyncURL struct { + Host string + Port string + Path string + Rendered string +} + +func normalizeSyncURL(raw string, target bool) (normalizedSyncURL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return normalizedSyncURL{}, errors.New("URL mapping value is empty") + } + + parseValue := raw + if !strings.Contains(raw, "://") { + parseValue = "https://" + raw + } + u, err := url.Parse(parseValue) + if err != nil || u.Hostname() == "" || u.User != nil { + return normalizedSyncURL{}, fmt.Errorf("invalid URL mapping value %q", raw) + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return normalizedSyncURL{}, fmt.Errorf("unsupported URL scheme %q", u.Scheme) + } + if u.RawQuery != "" || u.Fragment != "" { + return normalizedSyncURL{}, fmt.Errorf("URL mapping value %q cannot contain a query or fragment", raw) + } + + host, err := idna.Lookup.ToASCII(strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")) + if err != nil || !validDNSHost(host) { + return normalizedSyncURL{}, fmt.Errorf("invalid hostname %q", u.Hostname()) + } + port := u.Port() + if target && port != "" { + return normalizedSyncURL{}, errors.New("local mapping targets cannot include a port") + } + path := strings.TrimSuffix(u.EscapedPath(), "/") + if path == "/" { + path = "" + } + renderedHost := host + if port != "" { + renderedHost += ":" + port + } + return normalizedSyncURL{ + Host: host, + Port: port, + Path: path, + Rendered: renderedHost + path, + }, nil +} + +func validDNSHost(host string) bool { + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, c := range label { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + return false + } + } + } + return true +} + +type explicitSyncMapping struct { + source normalizedSyncURL + target normalizedSyncURL + origin MappingOrigin + used bool +} + +func parseExplicitPairs(values []string, origin MappingOrigin, baseHost string) ([]explicitSyncMapping, error) { + out := make([]explicitSyncMapping, 0, len(values)) + seen := map[string]string{} + for _, value := range values { + rawSource, rawTarget, ok := strings.Cut(value, ",") + if !ok { + return nil, fmt.Errorf("invalid search-replace mapping %q: expected source,target", value) + } + source, err := normalizeSyncURL(rawSource, false) + if err != nil { + return nil, fmt.Errorf("invalid mapping source: %w", err) + } + target, err := normalizeSyncURL(rawTarget, true) + if err != nil { + return nil, fmt.Errorf("invalid mapping target: %w", err) + } + if !targetInsideNamespace(target.Host, baseHost) { + return nil, fmt.Errorf("mapping target %q is outside the local environment namespace %q", target.Rendered, baseHost) + } + if previous, exists := seen[source.Rendered]; exists && previous != target.Rendered { + return nil, fmt.Errorf("conflicting %s mappings for %q", origin, source.Rendered) + } + if previous, exists := seen[source.Rendered]; exists && previous == target.Rendered { + continue + } + seen[source.Rendered] = target.Rendered + out = append(out, explicitSyncMapping{source: source, target: target, origin: origin}) + } + return out, nil +} + +func targetInsideNamespace(host, baseHost string) bool { + if host == baseHost { + return true + } + suffix := "." + baseHost + if !strings.HasSuffix(host, suffix) { + return false + } + prefix := strings.TrimSuffix(host, suffix) + return prefix != "" && !strings.Contains(prefix, ".") +} + +func pathPrefixMatch(path, prefix string) bool { + return path == prefix || strings.HasPrefix(path, prefix+"/") +} + +func appendURLPath(base, suffix string) string { + if suffix == "" { + return base + } + return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(suffix, "/") +} + +func explicitTargetFor(active normalizedSyncURL, mappings []explicitSyncMapping) (URLMapping, int, bool) { + best := -1 + bestPathLen := -1 + for i := range mappings { + candidate := mappings[i] + if candidate.source.Host != active.Host { + continue + } + if candidate.source.Port != "" && candidate.source.Port != active.Port { + continue + } + if candidate.source.Path != "" { + if !pathPrefixMatch(active.Path, candidate.source.Path) { + continue + } + if len(candidate.source.Path) > bestPathLen { + best = i + bestPathLen = len(candidate.source.Path) + } + continue + } + if best == -1 { + best = i + bestPathLen = 0 + } + } + if best < 0 { + return URLMapping{}, 0, false + } + candidate := mappings[best] + target := candidate.target.Rendered + if candidate.source.Path == "" { + target = appendURLPath(target, active.Path) + } else { + remainder := strings.TrimPrefix(active.Path, candidate.source.Path) + target = appendURLPath(target, remainder) + } + return URLMapping{Source: active.Rendered, Target: target, Origin: candidate.origin}, best, true +} + +type indexedSyncSite struct { + site SyncSite +} + +func indexSyncSites(sites []SyncSite) (string, map[string]indexedSyncSite, string) { + byBlogID := map[int64]SyncSite{} + conflictingIDs := map[int64]bool{} + for _, site := range sites { + if site.BlogID <= 0 { + continue + } + if previous, exists := byBlogID[site.BlogID]; exists { + if previous.HomeURL != site.HomeURL || previous.SiteURL != site.SiteURL { + conflictingIDs[site.BlogID] = true + } + continue + } + byBlogID[site.BlogID] = site + } + if len(conflictingIDs) > 0 { + return "", nil, "conflicting_blog_ids" + } + + primary, ok := byBlogID[1] + if !ok || strings.TrimSpace(primary.HomeURL) == "" { + return "", nil, "missing_primary_site" + } + primaryURL, err := normalizeSyncURL(primary.HomeURL, false) + if err != nil { + return "", nil, "invalid_primary_site" + } + + index := map[string]indexedSyncSite{} + ambiguous := map[string]bool{} + for _, site := range byBlogID { + for _, raw := range []string{site.HomeURL, site.SiteURL} { + if strings.TrimSpace(raw) == "" { + continue + } + normalized, err := normalizeSyncURL(raw, false) + if err != nil { + continue + } + if previous, exists := index[normalized.Rendered]; exists && previous.site.BlogID != site.BlogID { + ambiguous[normalized.Rendered] = true + continue + } + index[normalized.Rendered] = indexedSyncSite{site: site} + } + } + for raw := range ambiguous { + delete(index, raw) + } + return primaryURL.Host, index, "" +} + +func flattenDNSLabel(value string) string { + var b strings.Builder + lastHyphen := false + for _, c := range value { + isAlphaNum := c >= 'a' && c <= 'z' || c >= '0' && c <= '9' + if isAlphaNum { + b.WriteRune(c) + lastHyphen = false + continue + } + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + return strings.Trim(b.String(), "-") +} + +func flattenedLabel(host string, blogID int64, baseHost string) (string, error) { + if blogID <= 0 { + return "", errors.New("automatic flattened mappings require a positive blog ID") + } + readable := flattenDNSLabel(host) + suffix := "-b" + strconv.FormatInt(blogID, 10) + maxReadable := 63 - len(suffix) + if maxReadable < 1 { + return "", fmt.Errorf("blog ID %d is too long for a DNS label", blogID) + } + if len(readable) > maxReadable { + readable = strings.TrimRight(readable[:maxReadable], "-") + } + if readable == "" { + readable = "site" + if len(readable) > maxReadable { + readable = readable[:maxReadable] + } + } + label := readable + suffix + if !validDNSHost(label + "." + baseHost) { + return "", fmt.Errorf("generated hostname %q is not a valid DNS name", label+"."+baseHost) + } + return label, nil +} + +func automaticTarget(source normalizedSyncURL, primaryHost, baseHost string, blogID int64) (string, error) { + targetHost := baseHost + if source.Host != primaryHost { + if relative, ok := strings.CutSuffix(source.Host, "."+primaryHost); ok && relative != "" { + if !strings.Contains(relative, ".") { + targetHost = relative + "." + baseHost + } else { + label, err := flattenedLabel(relative, blogID, baseHost) + if err != nil { + return "", err + } + targetHost = label + "." + baseHost + } + } else { + label, err := flattenedLabel(source.Host, blogID, baseHost) + if err != nil { + return "", err + } + targetHost = label + "." + baseHost + } + } + if !targetInsideNamespace(targetHost, baseHost) || !validDNSHost(targetHost) { + return "", fmt.Errorf("generated hostname %q is outside the routable namespace", targetHost) + } + return targetHost + source.Path, nil +} + +func normalizeActiveURLs(values []string) ([]normalizedSyncURL, error) { + seen := map[string]bool{} + out := make([]normalizedSyncURL, 0, len(values)) + for _, value := range values { + normalized, err := normalizeSyncURL(value, false) + if err != nil { + return nil, fmt.Errorf("invalid active site URL: %w", err) + } + if seen[normalized.Rendered] { + continue + } + seen[normalized.Rendered] = true + out = append(out, normalized) + } + return out, nil +} + +func buildDomainRepairs(mappings []URLMapping) ([]DomainRepair, error) { + bySourceTarget := map[string]map[string]bool{} + seen := map[string]bool{} + var out []DomainRepair + for _, mapping := range mappings { + source, err := normalizeSyncURL(mapping.Source, false) + if err != nil { + return nil, err + } + target, err := normalizeSyncURL(mapping.Target, true) + if err != nil { + return nil, err + } + if source.Host == target.Host { + continue + } + if bySourceTarget[source.Host] == nil { + bySourceTarget[source.Host] = map[string]bool{} + } + bySourceTarget[source.Host][target.Host] = true + if len(bySourceTarget[source.Host]) > 1 { + return nil, fmt.Errorf("conflicting domain repair for %q", source.Host) + } + key := fmt.Sprintf("%d\x00%s\x00%s", mapping.BlogID, source.Host, target.Host) + if seen[key] { + continue + } + seen[key] = true + out = append(out, DomainRepair{ + BlogID: mapping.BlogID, + SourceDomain: source.Host, + TargetDomain: target.Host, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].BlogID != out[j].BlogID { + return out[i].BlogID < out[j].BlogID + } + if out[i].SourceDomain != out[j].SourceDomain { + return out[i].SourceDomain < out[j].SourceDomain + } + return out[i].TargetDomain < out[j].TargetDomain + }) + return out, nil +} + +// BuildSyncPlan returns a deterministic, side-effect-free draft. Unresolved +// mappings are data, not errors, so a caller may collect recovery pairs and +// rebuild the plan before any import takes place. +func BuildSyncPlan(input PlanInput) (PlanDraft, error) { + base, err := normalizeSyncURL(input.BaseHost, true) + if err != nil || base.Path != "" { + if err == nil { + err = errors.New("base host cannot include a path") + } + return PlanDraft{}, fmt.Errorf("invalid local base host: %w", err) + } + active, err := normalizeActiveURLs(input.ActiveURLs) + if err != nil { + return PlanDraft{}, err + } + + overrides, err := parseExplicitPairs(input.Overrides, MappingOverride, base.Host) + if err != nil { + return PlanDraft{}, err + } + recoveries, err := parseExplicitPairs(input.Recoveries, MappingRecovery, base.Host) + if err != nil { + return PlanDraft{}, err + } + explicit := append(overrides, recoveries...) + + primaryHost := "" + siteIndex := map[string]indexedSyncSite{} + catalogIssue := input.CatalogIssue + if input.IsMultisite && catalogIssue == "" { + primaryHost, siteIndex, catalogIssue = indexSyncSites(input.Sites) + } + + var mappings []URLMapping + var unresolved []UnresolvedMapping + for _, source := range active { + if mapping, explicitIndex, ok := explicitTargetFor(source, explicit); ok { + explicit[explicitIndex].used = true + if mapping.Origin == MappingOverride { + if indexed, exists := siteIndex[source.Rendered]; exists { + mapping.BlogID = indexed.site.BlogID + } + } + mappings = append(mappings, mapping) + continue + } + + if !input.IsMultisite { + mappings = append(mappings, URLMapping{ + Source: source.Rendered, + Target: base.Host + source.Path, + Origin: MappingSDS, + }) + continue + } + if catalogIssue != "" { + unresolved = append(unresolved, UnresolvedMapping{Source: source.Rendered, Reason: catalogIssue}) + continue + } + indexed, exists := siteIndex[source.Rendered] + if !exists { + unresolved = append(unresolved, UnresolvedMapping{Source: source.Rendered, Reason: "missing_sds_mapping"}) + continue + } + target, targetErr := automaticTarget(source, primaryHost, base.Host, indexed.site.BlogID) + if targetErr != nil { + unresolved = append(unresolved, UnresolvedMapping{Source: source.Rendered, Reason: targetErr.Error()}) + continue + } + mappings = append(mappings, URLMapping{ + Source: source.Rendered, + Target: target, + BlogID: indexed.site.BlogID, + Origin: MappingSDS, + }) + } + + // Explicit mappings are recovery inputs in their own right. Retain a pair + // that was not matched by SQL URL extraction so unusual dumps still have a + // deliberate user-controlled escape hatch. + for _, mapping := range explicit { + if mapping.used { + continue + } + mappings = append(mappings, URLMapping{ + Source: mapping.source.Rendered, + Target: mapping.target.Rendered, + Origin: mapping.origin, + }) + } + + // Deduplicate exact mappings and reject any source assigned two targets. + bySource := map[string]URLMapping{} + for _, mapping := range mappings { + if previous, exists := bySource[mapping.Source]; exists { + if previous.Target != mapping.Target { + return PlanDraft{}, fmt.Errorf("conflicting URL mappings for %q", mapping.Source) + } + continue + } + bySource[mapping.Source] = mapping + } + mappings = mappings[:0] + for _, mapping := range bySource { + mappings = append(mappings, mapping) + } + sort.Slice(mappings, func(i, j int) bool { + if len(mappings[i].Source) != len(mappings[j].Source) { + return len(mappings[i].Source) > len(mappings[j].Source) + } + return mappings[i].Source < mappings[j].Source + }) + sort.Slice(unresolved, func(i, j int) bool { return unresolved[i].Source < unresolved[j].Source }) + + repairs := []DomainRepair(nil) + if input.IsMultisite { + repairs, err = buildDomainRepairs(mappings) + if err != nil { + return PlanDraft{}, err + } + } + hostSet := map[string]bool{} + for _, mapping := range mappings { + target, targetErr := normalizeSyncURL(mapping.Target, true) + if targetErr != nil { + return PlanDraft{}, targetErr + } + hostSet[target.Host] = true + } + hosts := make([]string, 0, len(hostSet)) + for host := range hostSet { + hosts = append(hosts, host) + } + sort.Strings(hosts) + + return PlanDraft{ + Plan: SyncPlan{ + SearchReplace: mappings, + DomainRepairs: repairs, + RequiredHosts: hosts, + }, + Unresolved: unresolved, + }, nil +} diff --git a/internal/devenv/syncplan_test.go b/internal/devenv/syncplan_test.go new file mode 100644 index 000000000..b3edcd390 --- /dev/null +++ b/internal/devenv/syncplan_test.go @@ -0,0 +1,256 @@ +package devenv + +import ( + "strings" + "testing" +) + +func mappingsBySource(t *testing.T, draft PlanDraft) map[string]URLMapping { + t.Helper() + out := make(map[string]URLMapping, len(draft.Plan.SearchReplace)) + for _, mapping := range draft.Plan.SearchReplace { + out[mapping.Source] = mapping + } + return out +} + +func TestBuildSyncPlanAutomaticTargets(t *testing.T) { + input := PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{ + "https://domain.com/", + "https://domain.com/subsite/", + "https://subsite.domain.com", + "https://sub.subsite.domain.com", + "https://mapped.example.net", + }, + Sites: []SyncSite{ + {BlogID: 1, HomeURL: "https://domain.com", SiteURL: "https://domain.com/wp"}, + {BlogID: 2, HomeURL: "https://domain.com/subsite/", SiteURL: "https://domain.com/subsite/wp"}, + {BlogID: 3, HomeURL: "https://subsite.domain.com", SiteURL: "https://subsite.domain.com/wp"}, + {BlogID: 7, HomeURL: "https://sub.subsite.domain.com", SiteURL: "https://sub.subsite.domain.com/wp"}, + {BlogID: 9, HomeURL: "https://mapped.example.net", SiteURL: "https://mapped.example.net/wp"}, + }, + } + + draft, err := BuildSyncPlan(input) + if err != nil { + t.Fatal(err) + } + if len(draft.Unresolved) != 0 { + t.Fatalf("unresolved = %#v", draft.Unresolved) + } + got := mappingsBySource(t, draft) + want := map[string]string{ + "domain.com": "mysite.vipdev.site", + "domain.com/subsite": "mysite.vipdev.site/subsite", + "subsite.domain.com": "subsite.mysite.vipdev.site", + "sub.subsite.domain.com": "sub-subsite-b7.mysite.vipdev.site", + "mapped.example.net": "mapped-example-net-b9.mysite.vipdev.site", + } + for source, target := range want { + mapping, ok := got[source] + if !ok { + t.Errorf("missing mapping for %q in %#v", source, got) + continue + } + if mapping.Target != target || mapping.Origin != MappingSDS { + t.Errorf("mapping[%q] = %#v, want target=%q origin=%q", source, mapping, target, MappingSDS) + } + } + if len(got) != len(want) { + t.Fatalf("mapping count = %d, want %d: %#v", len(got), len(want), got) + } +} + +func TestBuildSyncPlanExplicitPrecedenceAndRecovery(t *testing.T) { + input := PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{ + "https://mapped.example.net", + "https://mapped.example.net/shop", + "https://missing.example.org/path", + }, + Sites: []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com"}, + {BlogID: 9, HomeURL: "https://mapped.example.net"}, + }, + Overrides: []string{ + "mapped.example.net,custom.mysite.vipdev.site", + "mapped.example.net/shop,custom.mysite.vipdev.site/special", + }, + Recoveries: []string{ + "missing.example.org,recovered.mysite.vipdev.site", + }, + } + + draft, err := BuildSyncPlan(input) + if err != nil { + t.Fatal(err) + } + if len(draft.Unresolved) != 0 { + t.Fatalf("unresolved = %#v", draft.Unresolved) + } + got := mappingsBySource(t, draft) + if mapping := got["mapped.example.net"]; mapping.Target != "custom.mysite.vipdev.site" || mapping.Origin != MappingOverride { + t.Errorf("host override = %#v", mapping) + } + if mapping := got["mapped.example.net/shop"]; mapping.Target != "custom.mysite.vipdev.site/special" || mapping.Origin != MappingOverride { + t.Errorf("path override = %#v", mapping) + } + if mapping := got["missing.example.org/path"]; mapping.Target != "recovered.mysite.vipdev.site/path" || mapping.Origin != MappingRecovery { + t.Errorf("host recovery = %#v", mapping) + } +} + +func TestBuildSyncPlanSDSOutageCanBeRecovered(t *testing.T) { + input := PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://mapped.example.net"}, + CatalogIssue: "transport", + } + + draft, err := BuildSyncPlan(input) + if err != nil { + t.Fatal(err) + } + if len(draft.Unresolved) != 1 || draft.Unresolved[0].Source != "mapped.example.net" { + t.Fatalf("unresolved = %#v", draft.Unresolved) + } + + input.Recoveries = []string{"mapped.example.net,mapped.mysite.vipdev.site"} + draft, err = BuildSyncPlan(input) + if err != nil { + t.Fatal(err) + } + if len(draft.Unresolved) != 0 { + t.Fatalf("recovered draft unresolved = %#v", draft.Unresolved) + } + if got := mappingsBySource(t, draft)["mapped.example.net"]; got.Target != "mapped.mysite.vipdev.site" || got.BlogID != 0 { + t.Fatalf("recovery mapping = %#v", got) + } +} + +func TestBuildSyncPlanPartialExportOnlyRepairsActiveSites(t *testing.T) { + draft, err := BuildSyncPlan(PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://second.primary.example.com"}, + Sites: []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com"}, + {BlogID: 2, HomeURL: "https://second.primary.example.com"}, + {BlogID: 3, HomeURL: "https://third.primary.example.com"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if len(draft.Plan.DomainRepairs) != 1 || draft.Plan.DomainRepairs[0].BlogID != 2 { + t.Fatalf("domain repairs = %#v, want only blog 2", draft.Plan.DomainRepairs) + } +} + +func TestBuildSyncPlanNormalizesIDNAAndSourcePorts(t *testing.T) { + draft, err := BuildSyncPlan(PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"HTTPS://BÜCHER.example:8080/"}, + Sites: []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com"}, + {BlogID: 4, HomeURL: "https://xn--bcher-kva.example:8080"}, + }, + }) + if err != nil { + t.Fatal(err) + } + got := mappingsBySource(t, draft)["xn--bcher-kva.example:8080"] + if got.Target != "xn-bcher-kva-example-b4.mysite.vipdev.site" { + t.Fatalf("IDNA mapping = %#v", got) + } + if len(draft.Plan.DomainRepairs) != 1 || draft.Plan.DomainRepairs[0].SourceDomain != "xn--bcher-kva.example" { + t.Fatalf("IDNA repair = %#v", draft.Plan.DomainRepairs) + } +} + +func TestBuildSyncPlanRejectsUnroutableExplicitTargets(t *testing.T) { + for _, pair := range []string{ + "source.example.com,foreign.example.com", + "source.example.com,deep.label.mysite.vipdev.site", + "source.example.com,mapped.mysite.vipdev.site:8443", + } { + t.Run(pair, func(t *testing.T) { + _, err := BuildSyncPlan(PlanInput{ + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://source.example.com"}, + Overrides: []string{pair}, + }) + if err == nil { + t.Fatalf("BuildSyncPlan accepted %q", pair) + } + }) + } +} + +func TestBuildSyncPlanRejectsConflictingDomainRepairs(t *testing.T) { + _, err := BuildSyncPlan(PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://source.example.com/a", "https://source.example.com/b"}, + CatalogIssue: "transport", + Overrides: []string{ + "source.example.com/a,a.mysite.vipdev.site", + "source.example.com/b,b.mysite.vipdev.site", + }, + }) + if err == nil || !strings.Contains(err.Error(), "conflicting domain repair") { + t.Fatalf("err = %v, want conflicting domain repair", err) + } +} + +func TestBuildSyncPlanTruncatesFlattenedLabelsAndSortsLongestFirst(t *testing.T) { + longLabel := strings.Repeat("a", 50) + "." + strings.Repeat("b", 40) + ".example.net" + draft, err := BuildSyncPlan(PlanInput{ + IsMultisite: true, + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://primary.example.com", "https://primary.example.com/a/long/path", "https://" + longLabel}, + Sites: []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com"}, + {BlogID: 2, HomeURL: "https://primary.example.com/a/long/path"}, + {BlogID: 123, HomeURL: "https://" + longLabel}, + }, + }) + if err != nil { + t.Fatal(err) + } + if got := draft.Plan.SearchReplace; len(got) != 3 || got[1].Source != "primary.example.com/a/long/path" || got[2].Source != "primary.example.com" { + t.Fatalf("search-replace order = %#v", got) + } + for i := 1; i < len(draft.Plan.SearchReplace); i++ { + if len(draft.Plan.SearchReplace[i-1].Source) < len(draft.Plan.SearchReplace[i].Source) { + t.Fatalf("search-replace is not longest-first: %#v", draft.Plan.SearchReplace) + } + } + for _, host := range draft.Plan.RequiredHosts { + label := strings.Split(host, ".")[0] + if len(label) > 63 { + t.Fatalf("generated label %q is %d bytes", label, len(label)) + } + } +} + +func TestBuildSyncPlanSingleSitePreservesPaths(t *testing.T) { + draft, err := BuildSyncPlan(PlanInput{ + BaseHost: "mysite.vipdev.site", + ActiveURLs: []string{"https://single.example.com", "https://single.example.com/wp"}, + }) + if err != nil { + t.Fatal(err) + } + got := mappingsBySource(t, draft) + if got["single.example.com"].Target != "mysite.vipdev.site" || got["single.example.com/wp"].Target != "mysite.vipdev.site/wp" { + t.Fatalf("single-site mappings = %#v", got) + } +} diff --git a/internal/devenv/syncsql.go b/internal/devenv/syncsql.go new file mode 100644 index 000000000..4181463cb --- /dev/null +++ b/internal/devenv/syncsql.go @@ -0,0 +1,247 @@ +package devenv + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// findSiteHomeURL extracts a siteurl/home URL from a SQL line, or "". +// Ports findSiteHomeUrl (dev-env-sync-sql.ts). +// +// Note: Go's regexp/RE2 does not support backreferences (\1), so we enumerate +// both quote flavours explicitly with two alternation branches. +var siteHomeRe = regexp.MustCompile( + `(?:'(?:siteurl|home)',\s*'([Hh][Tt][Tt][Pp][Ss]?://[^']+)'` + + `|"(?:siteurl|home)",\s*"([Hh][Tt][Tt][Pp][Ss]?://[^"]+)")`, +) + +func findSiteHomeURL(sql string) string { + m := siteHomeRe.FindStringSubmatch(sql) + if m == nil { + return "" + } + // m[1] is the single-quote capture, m[2] is the double-quote capture. + raw := m[1] + if raw == "" { + raw = m[2] + } + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Hostname() == "" { + return "" + } + return raw +} + +// extractSiteURLs scans a SQL stream for siteurl/home URLs, dedupes, strips a +// trailing slash, and sorts longest-first (so longest URLs replace first). +// Ports extractSiteUrls (dev-env-sync-sql.ts). +func extractSiteURLs(r io.Reader) ([]string, error) { + set := map[string]struct{}{} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 1024*1024), 16*1024*1024) // SQL lines can be long + for sc.Scan() { + u := findSiteHomeURL(sc.Text()) + if u == "" { + continue + } + u = strings.TrimRight(u, "/") + set[u] = struct{}{} + } + if err := sc.Err(); err != nil { + return nil, err + } + out := make([]string, 0, len(set)) + for u := range set { + out = append(out, u) + } + sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) }) + return out, nil +} + +// landoDomainFor returns "<slug>.<domain>" for the search-replace target. +func landoDomainFor(slug, domain string) string { return slug + "." + domain } + +type SyncOptions struct { + Slug string + Domain string + IsMultisite bool + Overrides []string +} + +var ErrSyncCancelled = errors.New("SQL sync cancelled") + +type UnresolvedMappingsError struct { + Mappings []UnresolvedMapping +} + +func (e *UnresolvedMappingsError) Error() string { + if e == nil || len(e.Mappings) == 0 { + return "multisite sync has unresolved URL mappings" + } + values := make([]string, 0, len(e.Mappings)) + for _, mapping := range e.Mappings { + values = append(values, mapping.Source) + } + return "multisite sync has unresolved URL mappings: " + strings.Join(values, ", ") +} + +type HostRefreshError struct { + Slug string + Err error +} + +func (e *HostRefreshError) Error() string { + return fmt.Sprintf( + "SQL sync completed, but offline hostname setup is incomplete.\nRun `vip dev-env start --slug %s` to retry host configuration: %v", + e.Slug, + e.Err, + ) +} + +func (e *HostRefreshError) Unwrap() error { return e.Err } + +// SyncDeps injects every I/O boundary so planning and exactly-once import +// behavior can be verified without Docker or a remote API. +type SyncDeps struct { + // ExportTo exports the production DB to a local SQL file at dest. + ExportTo func(ctx context.Context, dest string) error + // FetchSites reads the complete SDS catalog. A non-empty issue means the + // catalog is unsafe for automatic mapping and explicit recovery is required. + FetchSites func(ctx context.Context) (sites []SyncSite, issue string) + // ResolveDraft supplies explicit recovery pairs for an unresolved plan. + ResolveDraft func(draft PlanDraft) ([]string, error) + // ImportFile imports file into the local env, applying "from,to" pairs. + ImportFile func(ctx context.Context, slug, file string, pairs []string) error + // RepairDomains applies guarded wp_blogs updates after a successful import. + RepairDomains func(ctx context.Context, slug string, repairs []DomainRepair) error + // RefreshHosts rebuilds the globally owned offline hosts snapshot. + RefreshHosts func(ctx context.Context) error + // Log, when set, receives progress messages (Node parity console.log lines). + Log func(msg string) +} + +// syncSQLWith runs export -> inspect -> SDS -> plan/recovery -> import -> +// guarded repair -> offline host refresh. Planning and prompting finish before +// ImportFile is called, and ImportFile is called at most once. +func syncSQLWith(ctx context.Context, options SyncOptions, deps SyncDeps) error { + logf := func(msg string) { + if deps.Log != nil { + deps.Log(msg) + } + } + if options.Slug == "" || options.Domain == "" { + return errors.New("devenv: sync slug and domain are required") + } + if deps.ExportTo == nil || deps.ImportFile == nil || deps.RepairDomains == nil || deps.RefreshHosts == nil { + return errors.New("devenv: incomplete SQL sync dependencies") + } + if options.IsMultisite && deps.FetchSites == nil { + return errors.New("devenv: multisite SQL sync requires an SDS catalog adapter") + } + + tmp, err := os.MkdirTemp("", "vip-dev-env-sync-*") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + + sqlFile := filepath.Join(tmp, "sql-export.sql") + if err := deps.ExportTo(ctx, sqlFile); err != nil { + return err + } + + logf("Extracting site urls from the SQL file...") + f, err := os.Open(sqlFile) + if err != nil { + return err + } + urls, err := extractSiteURLs(f) + _ = f.Close() + if err != nil { + return err + } + + var sites []SyncSite + catalogIssue := "" + if options.IsMultisite { + logf("Fetching list of sites for database sync...") + sites, catalogIssue = deps.FetchSites(ctx) + } + + logf("Generating search-replace configuration...") + planInput := PlanInput{ + IsMultisite: options.IsMultisite, + BaseHost: landoDomainFor(options.Slug, options.Domain), + ActiveURLs: urls, + Sites: sites, + Overrides: options.Overrides, + CatalogIssue: catalogIssue, + } + draft, err := BuildSyncPlan(planInput) + if err != nil { + return err + } + if len(draft.Unresolved) > 0 { + if deps.ResolveDraft == nil { + return &UnresolvedMappingsError{Mappings: draft.Unresolved} + } + recoveries, resolveErr := deps.ResolveDraft(draft) + if errors.Is(resolveErr, ErrSyncCancelled) { + logf("SQL sync cancelled; local database was not modified.") + return nil + } + if resolveErr != nil { + return resolveErr + } + planInput.Recoveries = recoveries + draft, err = BuildSyncPlan(planInput) + if err != nil { + return err + } + if len(draft.Unresolved) > 0 { + return &UnresolvedMappingsError{Mappings: draft.Unresolved} + } + } + + pairs := make([]string, 0, len(draft.Plan.SearchReplace)) + for _, mapping := range draft.Plan.SearchReplace { + pairs = append(pairs, mapping.Source+","+mapping.Target) + } + + logf("Running the following search-replace operations on the SQL file:") + for _, mapping := range draft.Plan.SearchReplace { + logf(fmt.Sprintf(" [%s] %s -> %s", mapping.Origin, mapping.Source, mapping.Target)) + } + + logf("Importing the SQL file...") + if err := deps.ImportFile(ctx, options.Slug, sqlFile, pairs); err != nil { + return err + } + logf("✓ SQL file imported") + if err := deps.RepairDomains(ctx, options.Slug, draft.Plan.DomainRepairs); err != nil { + return err + } + if err := deps.RefreshHosts(ctx); err != nil { + return &HostRefreshError{Slug: options.Slug, Err: err} + } + return nil +} + +// SyncSQL executes a fully-adapted SQL sync. The command layer supplies the +// platform and interactive boundaries; internal/devenv owns local import, +// repair, and host behavior through those injected functions. +func SyncSQL(ctx context.Context, options SyncOptions, deps SyncDeps) error { + return syncSQLWith(ctx, options, deps) +} diff --git a/internal/devenv/syncsql_repair.go b/internal/devenv/syncsql_repair.go new file mode 100644 index 000000000..f5ea52e06 --- /dev/null +++ b/internal/devenv/syncsql_repair.go @@ -0,0 +1,169 @@ +package devenv + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strings" +) + +const syncDomainRepairProcedure = "vip_sync_update_blog_domains" + +type PostImportRepairError struct { + Err error + RepairsCommitted bool +} + +func (e *PostImportRepairError) Error() string { + if e == nil { + return "" + } + if e.RepairsCommitted { + return fmt.Sprintf("the database was imported and domain repairs committed, but temporary repair cleanup failed: %v", e.Err) + } + return fmt.Sprintf("the database was imported, but multisite domain repair failed; no partial domain repairs were committed: %v", e.Err) +} + +func (e *PostImportRepairError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func quoteSQLString(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func normalizedDomainRepairs(repairs []DomainRepair) ([]DomainRepair, error) { + targetsBySource := map[string]string{} + seen := map[string]bool{} + out := make([]DomainRepair, 0, len(repairs)) + for _, repair := range repairs { + if repair.BlogID < 0 { + return nil, fmt.Errorf("invalid negative blog ID %d", repair.BlogID) + } + if !validDNSHost(repair.SourceDomain) || !validDNSHost(repair.TargetDomain) { + return nil, fmt.Errorf("invalid domain repair %q -> %q", repair.SourceDomain, repair.TargetDomain) + } + if target, exists := targetsBySource[repair.SourceDomain]; exists && target != repair.TargetDomain { + return nil, fmt.Errorf("conflicting domain repair for %q", repair.SourceDomain) + } + targetsBySource[repair.SourceDomain] = repair.TargetDomain + key := fmt.Sprintf("%d\x00%s\x00%s", repair.BlogID, repair.SourceDomain, repair.TargetDomain) + if seen[key] { + continue + } + seen[key] = true + out = append(out, repair) + } + sort.Slice(out, func(i, j int) bool { + if out[i].BlogID != out[j].BlogID { + return out[i].BlogID < out[j].BlogID + } + if out[i].SourceDomain != out[j].SourceDomain { + return out[i].SourceDomain < out[j].SourceDomain + } + return out[i].TargetDomain < out[j].TargetDomain + }) + return out, nil +} + +func buildDomainRepairSQL(repairs []DomainRepair) (string, error) { + repairs, err := normalizedDomainRepairs(repairs) + if err != nil { + return "", err + } + if len(repairs) == 0 { + return "", nil + } + + var b strings.Builder + fmt.Fprintf(&b, "DROP PROCEDURE IF EXISTS %s;\n", syncDomainRepairProcedure) + b.WriteString("DELIMITER $$\n") + fmt.Fprintf(&b, "CREATE PROCEDURE %s()\n", syncDomainRepairProcedure) + b.WriteString("BEGIN\n") + b.WriteString(" DECLARE EXIT HANDLER FOR SQLEXCEPTION\n") + b.WriteString(" BEGIN\n") + b.WriteString(" ROLLBACK;\n") + b.WriteString(" RESIGNAL;\n") + b.WriteString(" END;\n") + b.WriteString(" IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'wordpress' AND table_name = 'wp_blogs') THEN\n") + b.WriteString(" START TRANSACTION;\n") + for _, repair := range repairs { + fmt.Fprintf(&b, " UPDATE wordpress.wp_blogs SET domain = %s WHERE ", quoteSQLString(repair.TargetDomain)) + if repair.BlogID > 0 { + fmt.Fprintf(&b, "blog_id = %d AND ", repair.BlogID) + } + fmt.Fprintf(&b, "domain = %s;\n", quoteSQLString(repair.SourceDomain)) + } + b.WriteString(" COMMIT;\n") + b.WriteString(" END IF;\n") + b.WriteString("END$$\n") + b.WriteString("DELIMITER ;\n") + fmt.Fprintf(&b, "CALL %s();\n", syncDomainRepairProcedure) + return b.String(), nil +} + +type domainRepairRunner interface { + Compose(context.Context, string, ...string) error + ComposeStdin(context.Context, string, io.Reader, ...string) error +} + +func domainRepairCleanupArgs() []string { + return []string{ + "exec", "-T", phpService, + "wp", "--allow-root", "db", "query", + "DROP PROCEDURE IF EXISTS " + syncDomainRepairProcedure, + } +} + +func domainRepairQueryArgs() []string { + return []string{"exec", "-T", phpService, "wp", "--allow-root", "db", "query"} +} + +func repairBlogDomainsWith(ctx context.Context, runner domainRepairRunner, slug string, repairs []DomainRepair) (resultErr error) { + if len(repairs) == 0 { + return nil + } + script, err := buildDomainRepairSQL(repairs) + if err != nil { + return &PostImportRepairError{Err: err} + } + cleanupArgs := domainRepairCleanupArgs() + if err := runner.Compose(ctx, slug, cleanupArgs...); err != nil { + return &PostImportRepairError{Err: fmt.Errorf("prepare repair procedure: %w", err)} + } + defer func() { + if cleanupErr := runner.Compose(ctx, slug, cleanupArgs...); cleanupErr != nil { + if resultErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("cleanup repair procedure: %w", cleanupErr)) + return + } + resultErr = &PostImportRepairError{ + Err: fmt.Errorf("cleanup repair procedure: %w", cleanupErr), + RepairsCommitted: true, + } + } + }() + if err := runner.ComposeStdin(ctx, slug, strings.NewReader(script), domainRepairQueryArgs()...); err != nil { + return &PostImportRepairError{Err: fmt.Errorf("execute repair transaction: %w", err)} + } + return nil +} + +// RepairBlogDomains applies the final plan's typed domain repairs to the local +// database. The procedure handler rolls back every repair if any update fails; +// cleanup is attempted independently before and after execution. +func RepairBlogDomains(ctx context.Context, slug string, repairs []DomainRepair) error { + if len(repairs) == 0 { + return nil + } + runner, err := newRunner(ctx) + if err != nil { + return &PostImportRepairError{Err: err} + } + return repairBlogDomainsWith(ctx, runner, slug, repairs) +} diff --git a/internal/devenv/syncsql_repair_test.go b/internal/devenv/syncsql_repair_test.go new file mode 100644 index 000000000..11dae608f --- /dev/null +++ b/internal/devenv/syncsql_repair_test.go @@ -0,0 +1,150 @@ +package devenv + +import ( + "context" + "errors" + "io" + "strings" + "testing" +) + +func TestBuildDomainRepairSQLUsesGuardedTransaction(t *testing.T) { + script, err := buildDomainRepairSQL([]DomainRepair{ + {BlogID: 9, SourceDomain: "mapped.example.net", TargetDomain: "mapped-example-net-b9.mysite.vipdev.site"}, + {BlogID: 0, SourceDomain: "recovery.example.net", TargetDomain: "recovered.mysite.vipdev.site"}, + {BlogID: 9, SourceDomain: "mapped.example.net", TargetDomain: "mapped-example-net-b9.mysite.vipdev.site"}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "DROP PROCEDURE IF EXISTS vip_sync_update_blog_domains;", + "DECLARE EXIT HANDLER FOR SQLEXCEPTION", + "ROLLBACK;", + "RESIGNAL;", + "information_schema.tables", + "table_schema = 'wordpress' AND table_name = 'wp_blogs'", + "START TRANSACTION;", + "UPDATE wordpress.wp_blogs SET domain = 'mapped-example-net-b9.mysite.vipdev.site' WHERE blog_id = 9 AND domain = 'mapped.example.net';", + "UPDATE wordpress.wp_blogs SET domain = 'recovered.mysite.vipdev.site' WHERE domain = 'recovery.example.net';", + "COMMIT;", + "CALL vip_sync_update_blog_domains();", + } { + if !strings.Contains(script, want) { + t.Errorf("script missing %q:\n%s", want, script) + } + } + if got := strings.Count(script, "mapped-example-net-b9.mysite.vipdev.site"); got != 1 { + t.Fatalf("deduplicated target count = %d, want 1:\n%s", got, script) + } + if strings.Index(script, "START TRANSACTION;") > strings.Index(script, "UPDATE wordpress.wp_blogs") || + strings.Index(script, "UPDATE wordpress.wp_blogs") > strings.Index(script, "COMMIT;") { + t.Fatalf("transaction statement order is wrong:\n%s", script) + } +} + +func TestBuildDomainRepairSQLRejectsConflictsAndInvalidDomains(t *testing.T) { + tests := []struct { + name string + repairs []DomainRepair + }{ + { + name: "conflicting targets", + repairs: []DomainRepair{ + {BlogID: 1, SourceDomain: "old.example.com", TargetDomain: "one.mysite.vipdev.site"}, + {BlogID: 2, SourceDomain: "old.example.com", TargetDomain: "two.mysite.vipdev.site"}, + }, + }, + { + name: "invalid source", + repairs: []DomainRepair{ + {BlogID: 1, SourceDomain: "old.example.com' OR 1=1 --", TargetDomain: "one.mysite.vipdev.site"}, + }, + }, + { + name: "negative blog id", + repairs: []DomainRepair{ + {BlogID: -1, SourceDomain: "old.example.com", TargetDomain: "one.mysite.vipdev.site"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := buildDomainRepairSQL(tt.repairs); err == nil { + t.Fatal("buildDomainRepairSQL accepted unsafe repairs") + } + }) + } +} + +type recordingDomainRepairRunner struct { + composeCalls [][]string + composeStdinCalls [][]string + stdin string + stdinErr error + cleanupErr error +} + +func (r *recordingDomainRepairRunner) Compose(_ context.Context, _ string, args ...string) error { + r.composeCalls = append(r.composeCalls, append([]string(nil), args...)) + if len(r.composeCalls) > 1 { + return r.cleanupErr + } + return nil +} + +func (r *recordingDomainRepairRunner) ComposeStdin(_ context.Context, _ string, input io.Reader, args ...string) error { + r.composeStdinCalls = append(r.composeStdinCalls, append([]string(nil), args...)) + b, _ := io.ReadAll(input) + r.stdin = string(b) + return r.stdinErr +} + +func TestRepairBlogDomainsCleansProcedureAfterFailure(t *testing.T) { + runner := &recordingDomainRepairRunner{stdinErr: errors.New("local mysql failed")} + err := repairBlogDomainsWith(context.Background(), runner, "mysite", []DomainRepair{ + {BlogID: 9, SourceDomain: "mapped.example.net", TargetDomain: "mapped.mysite.vipdev.site"}, + }) + var postImportErr *PostImportRepairError + if !errors.As(err, &postImportErr) { + t.Fatalf("err = %T %v, want PostImportRepairError", err, err) + } + if !strings.Contains(err.Error(), "database was imported") || !strings.Contains(err.Error(), "no partial domain repairs were committed") { + t.Fatalf("error does not explain post-import state: %v", err) + } + if len(runner.composeStdinCalls) != 1 || !strings.Contains(runner.stdin, "CREATE PROCEDURE") { + t.Fatalf("stdin calls=%#v script=%q", runner.composeStdinCalls, runner.stdin) + } + if len(runner.composeCalls) != 2 { + t.Fatalf("cleanup calls = %#v, want preflight and deferred cleanup", runner.composeCalls) + } + for _, call := range runner.composeCalls { + if strings.Join(call, " ") != "exec -T php wp --allow-root db query DROP PROCEDURE IF EXISTS vip_sync_update_blog_domains" { + t.Fatalf("cleanup argv = %#v", call) + } + } +} + +func TestRepairBlogDomainsNoRepairsIsNoop(t *testing.T) { + runner := &recordingDomainRepairRunner{} + if err := repairBlogDomainsWith(context.Background(), runner, "mysite", nil); err != nil { + t.Fatal(err) + } + if len(runner.composeCalls) != 0 || len(runner.composeStdinCalls) != 0 { + t.Fatalf("runner called for empty repair set: %#v %#v", runner.composeCalls, runner.composeStdinCalls) + } +} + +func TestRepairBlogDomainsReportsCleanupFailureAfterCommit(t *testing.T) { + runner := &recordingDomainRepairRunner{cleanupErr: errors.New("drop failed")} + err := repairBlogDomainsWith(context.Background(), runner, "mysite", []DomainRepair{ + {BlogID: 9, SourceDomain: "mapped.example.net", TargetDomain: "mapped.mysite.vipdev.site"}, + }) + var postImportErr *PostImportRepairError + if !errors.As(err, &postImportErr) || !postImportErr.RepairsCommitted { + t.Fatalf("err = %#v, want committed cleanup error", err) + } + if !strings.Contains(err.Error(), "domain repairs committed") { + t.Fatalf("cleanup error state is ambiguous: %v", err) + } +} diff --git a/internal/devenv/syncsql_test.go b/internal/devenv/syncsql_test.go new file mode 100644 index 000000000..0bbcd2c22 --- /dev/null +++ b/internal/devenv/syncsql_test.go @@ -0,0 +1,220 @@ +package devenv + +import ( + "context" + "errors" + "os" + "reflect" + "strings" + "testing" +) + +func TestFindSiteHomeURL(t *testing.T) { + line := `('siteurl','https://example.com',` + if got := findSiteHomeURL(line); got != "https://example.com" { + t.Fatalf("findSiteHomeURL = %q", got) + } + if got := findSiteHomeURL(`('blogname','My Site',`); got != "" { + t.Fatalf("findSiteHomeURL non-url = %q", got) + } +} + +func TestExtractSiteURLsSortedByLengthDesc(t *testing.T) { + sql := strings.Join([]string{ + `('home','https://example.com',`, + `('siteurl','https://example.com/sub',`, + `('home','https://example.com',`, // duplicate + }, "\n") + got, err := extractSiteURLs(strings.NewReader(sql)) + if err != nil { + t.Fatal(err) + } + want := []string{"https://example.com/sub", "https://example.com"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("extractSiteURLs = %v, want %v", got, want) + } +} + +func TestSyncSQLOrchestration(t *testing.T) { + var events []string + var importPairs []string + deps := SyncDeps{ + ExportTo: func(_ context.Context, dest string) error { + events = append(events, "export") + return os.WriteFile(dest, []byte(`('siteurl','https://mapped.example.com',`), 0o644) + }, + FetchSites: func(context.Context) ([]SyncSite, string) { + events = append(events, "fetch") + return []SyncSite{ + {BlogID: 1, HomeURL: "https://primary.example.com"}, + {BlogID: 2, HomeURL: "https://mapped.example.com"}, + }, "" + }, + ImportFile: func(_ context.Context, slug, file string, pairs []string) error { + events = append(events, "import") + importPairs = pairs + return nil + }, + RepairDomains: func(_ context.Context, slug string, repairs []DomainRepair) error { + events = append(events, "repair") + if len(repairs) != 1 || repairs[0].BlogID != 2 { + t.Fatalf("repairs = %#v", repairs) + } + return nil + }, + RefreshHosts: func(context.Context) error { + events = append(events, "hosts") + return nil + }, + } + if err := syncSQLWith(context.Background(), SyncOptions{ + Slug: "mysite", Domain: "vipdev.site", IsMultisite: true, + }, deps); err != nil { + t.Fatal(err) + } + if want := []string{"export", "fetch", "import", "repair", "hosts"}; !reflect.DeepEqual(events, want) { + t.Fatalf("events = %#v, want %#v", events, want) + } + joined := strings.Join(importPairs, " ") + if !strings.Contains(joined, "mapped.example.com,mapped-example-com-b2.mysite.vipdev.site") { + t.Fatalf("search-replace pair missing/wrong: %v", importPairs) + } +} + +func TestSyncSQLRecoveryRebuildsBeforeExactlyOneImport(t *testing.T) { + var events []string + imports := 0 + deps := SyncDeps{ + ExportTo: func(_ context.Context, dest string) error { + events = append(events, "export") + return os.WriteFile(dest, []byte(`('home','https://missing.example.com',`), 0o644) + }, + FetchSites: func(context.Context) ([]SyncSite, string) { + events = append(events, "fetch") + return nil, "transport" + }, + ResolveDraft: func(draft PlanDraft) ([]string, error) { + events = append(events, "resolve") + if len(draft.Unresolved) != 1 || draft.Unresolved[0].Source != "missing.example.com" { + t.Fatalf("draft = %#v", draft) + } + return []string{"missing.example.com,recovered.mysite.vipdev.site"}, nil + }, + ImportFile: func(context.Context, string, string, []string) error { + events = append(events, "import") + imports++ + return nil + }, + RepairDomains: func(context.Context, string, []DomainRepair) error { + events = append(events, "repair") + return nil + }, + RefreshHosts: func(context.Context) error { + events = append(events, "hosts") + return nil + }, + } + if err := syncSQLWith(context.Background(), SyncOptions{ + Slug: "mysite", Domain: "vipdev.site", IsMultisite: true, + }, deps); err != nil { + t.Fatal(err) + } + if imports != 1 { + t.Fatalf("imports = %d, want exactly 1", imports) + } + if want := []string{"export", "fetch", "resolve", "import", "repair", "hosts"}; !reflect.DeepEqual(events, want) { + t.Fatalf("events = %#v, want %#v", events, want) + } +} + +func TestSyncSQLCancellationAndUnresolvedStopBeforeImport(t *testing.T) { + tests := []struct { + name string + resolve func(PlanDraft) ([]string, error) + wantErr bool + wantCancel bool + }{ + {name: "cancel", resolve: func(PlanDraft) ([]string, error) { return nil, ErrSyncCancelled }, wantCancel: true}, + {name: "still unresolved", resolve: func(PlanDraft) ([]string, error) { return nil, nil }, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imported := false + var logs []string + err := syncSQLWith(context.Background(), SyncOptions{ + Slug: "mysite", Domain: "vipdev.site", IsMultisite: true, + }, SyncDeps{ + ExportTo: func(_ context.Context, dest string) error { + return os.WriteFile(dest, []byte(`('home','https://missing.example.com',`), 0o644) + }, + FetchSites: func(context.Context) ([]SyncSite, string) { return nil, "transport" }, + ResolveDraft: tt.resolve, + ImportFile: func(context.Context, string, string, []string) error { + imported = true + return nil + }, + RepairDomains: func(context.Context, string, []DomainRepair) error { return nil }, + RefreshHosts: func(context.Context) error { return nil }, + Log: func(line string) { logs = append(logs, line) }, + }) + if tt.wantErr && err == nil { + t.Fatal("expected unresolved error") + } + if tt.wantCancel && (err != nil || !strings.Contains(strings.Join(logs, "\n"), "cancelled")) { + t.Fatalf("cancel err=%v logs=%#v", err, logs) + } + if imported { + t.Fatal("import called without a complete final plan") + } + }) + } +} + +func TestSyncSQLStopsLaterPhasesAfterFailure(t *testing.T) { + importErr := errors.New("import failed") + repairErr := errors.New("repair failed") + tests := []struct { + name string + importErr error + repairErr error + hostErr error + wantEvents []string + wantHost bool + }{ + {name: "import", importErr: importErr, wantEvents: []string{"import"}}, + {name: "repair", repairErr: repairErr, wantEvents: []string{"import", "repair"}}, + {name: "hosts", hostErr: errors.New("sudo declined"), wantEvents: []string{"import", "repair", "hosts"}, wantHost: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var events []string + err := syncSQLWith(context.Background(), SyncOptions{Slug: "mysite", Domain: "vipdev.site"}, SyncDeps{ + ExportTo: func(_ context.Context, dest string) error { + return os.WriteFile(dest, []byte(`('home','https://single.example.com',`), 0o644) + }, + ImportFile: func(context.Context, string, string, []string) error { + events = append(events, "import") + return tt.importErr + }, + RepairDomains: func(context.Context, string, []DomainRepair) error { + events = append(events, "repair") + return tt.repairErr + }, + RefreshHosts: func(context.Context) error { + events = append(events, "hosts") + return tt.hostErr + }, + }) + if err == nil { + t.Fatal("expected phase error") + } + if !reflect.DeepEqual(events, tt.wantEvents) { + t.Fatalf("events=%#v want=%#v", events, tt.wantEvents) + } + var hostErr *HostRefreshError + if errors.As(err, &hostErr) != tt.wantHost { + t.Fatalf("HostRefreshError=%t want=%t; err=%v", errors.As(err, &hostErr), tt.wantHost, err) + } + }) + } +} diff --git a/internal/devenv/update.go b/internal/devenv/update.go new file mode 100644 index 000000000..e5655552a --- /dev/null +++ b/internal/devenv/update.go @@ -0,0 +1,85 @@ +package devenv + +import ( + "context" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +// UpdateConfig carries only the fields the user chose to change. nil pointers +// mean "leave as-is" so an `update` with one flag doesn't reset everything. +type UpdateConfig struct { + PHP *string + WordPress *string + MuPluginsDir *string + AppCodeDir *string + Elasticsearch *bool + PHPMyAdmin *bool + Mailpit *bool + Xdebug *bool + XdebugConfig *string + Cron *bool + Photon *bool + MediaDomain *string +} + +// applyUpdate overlays the set fields of c onto d. +func applyUpdate(d *instancedata.InstanceData, c UpdateConfig) { + if c.PHP != nil { + d.PHP = *c.PHP + } + if c.WordPress != nil { + d.WordPress.Mode = "image" + d.WordPress.Tag = *c.WordPress + } + if c.MuPluginsDir != nil { + d.MuPlugins = componentConfig(*c.MuPluginsDir) + } + if c.AppCodeDir != nil { + d.AppCode = componentConfig(*c.AppCodeDir) + } + if c.Elasticsearch != nil { + if *c.Elasticsearch { + d.Elasticsearch = []byte("true") + } else { + d.Elasticsearch = []byte("false") + } + } + if c.PHPMyAdmin != nil { + d.PHPMyAdmin = *c.PHPMyAdmin + } + if c.Mailpit != nil { + d.Mailpit = *c.Mailpit + } + if c.Xdebug != nil { + d.Xdebug = *c.Xdebug + } + if c.XdebugConfig != nil { + d.XdebugConfig = *c.XdebugConfig + } + if c.Cron != nil { + d.Cron = *c.Cron + } + if c.Photon != nil { + d.Photon = *c.Photon + } + if c.MediaDomain != nil { + d.MediaRedirectDomain = *c.MediaDomain + } +} + +// Update reads, overlays, re-materializes, and persists an env's instance data. +// Like Node it does NOT restart — the caller instructs the user to start again. +func Update(ctx context.Context, slug string, c UpdateConfig) error { + d, err := instancedata.Read(slug) + if err != nil { + return err + } + applyUpdate(d, c) + if err := instancedata.Write(slug, d); err != nil { + return err + } + view := viewForData(d) + _, err = Materialize(slug, view) + return err +} diff --git a/internal/devenv/update_test.go b/internal/devenv/update_test.go new file mode 100644 index 000000000..bb86c3e5d --- /dev/null +++ b/internal/devenv/update_test.go @@ -0,0 +1,47 @@ +package devenv + +import ( + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestApplyUpdateOverlaysProvidedFields(t *testing.T) { + d := &instancedata.InstanceData{PHP: "8.1", PHPMyAdmin: false} + applyUpdate(d, UpdateConfig{ + PHP: strPtr("8.3"), + WordPress: strPtr("6.5"), + PHPMyAdmin: boolPtr(true), + }) + if d.PHP != "8.3" { + t.Fatalf("PHP = %q, want 8.3", d.PHP) + } + if d.WordPress.Tag != "6.5" { + t.Fatalf("WordPress.Tag = %q, want 6.5", d.WordPress.Tag) + } + if !d.PHPMyAdmin { + t.Fatal("PHPMyAdmin should be true") + } +} + +func TestApplyUpdateLeavesUnsetFields(t *testing.T) { + d := &instancedata.InstanceData{PHP: "8.1"} + applyUpdate(d, UpdateConfig{}) + if d.PHP != "8.1" { + t.Fatalf("PHP changed unexpectedly: %q", d.PHP) + } +} + +func strPtr(s string) *string { return &s } +func boolPtr(b bool) *bool { return &b } + +func TestApplyUpdateCronAndXdebugConfig(t *testing.T) { + d := &instancedata.InstanceData{} + applyUpdate(d, UpdateConfig{Cron: boolPtr(true), XdebugConfig: strPtr("idekey=VIP")}) + if !d.Cron { + t.Fatal("Cron should be true") + } + if d.XdebugConfig != "idekey=VIP" { + t.Fatalf("XdebugConfig = %q", d.XdebugConfig) + } +} diff --git a/internal/envalias/envalias.go b/internal/envalias/envalias.go new file mode 100644 index 000000000..716ce7761 --- /dev/null +++ b/internal/envalias/envalias.go @@ -0,0 +1,50 @@ +// Package envalias implements the @app.env pre-parser. +// +// The function Rewrite walks argv left-to-right, stops at the first "--", +// strips the FIRST @app[.env[.instance...]] token, and returns the +// rewritten argv plus the extracted (lowercased) app and env. A second +// alias-shaped token is left in place. Tokens that begin with "@" but +// do not match the alias regex pass through unchanged (Node behavior). +// +// Behavior matches the Node implementation in src/lib/cli/envAlias.ts. +package envalias + +import ( + "regexp" + "strings" +) + +// aliasRE matches the full Node isAlias pattern. +var aliasRE = regexp.MustCompile(`^@[A-Za-z0-9._-]+$`) + +func Rewrite(argv []string) (rewritten []string, app, env string, err error) { + rewritten = make([]string, 0, len(argv)) + consumed := false + + for i, tok := range argv { + if tok == "--" { + rewritten = append(rewritten, argv[i:]...) + return rewritten, app, env, nil + } + if !consumed && aliasRE.MatchString(tok) { + app, env = parseAlias(tok) + consumed = true + continue + } + rewritten = append(rewritten, tok) + } + return rewritten, app, env, nil +} + +// parseAlias strips "@", lowercases the remainder, splits on the first ".". +// The first segment is the app; the rest (joined on ".") is the env. +// Mirrors src/lib/cli/envAlias.ts:parseEnvAlias. +func parseAlias(tok string) (app, env string) { + stripped := strings.ToLower(tok[1:]) + parts := strings.SplitN(stripped, ".", 2) + app = parts[0] + if len(parts) == 2 { + env = parts[1] + } + return app, env +} diff --git a/internal/envalias/envalias_test.go b/internal/envalias/envalias_test.go new file mode 100644 index 000000000..9977ed4e6 --- /dev/null +++ b/internal/envalias/envalias_test.go @@ -0,0 +1,147 @@ +package envalias + +import ( + "reflect" + "testing" +) + +func TestRewrite(t *testing.T) { + tests := []struct { + name string + argv []string + wantArgv []string + wantApp string + wantEnv string + }{ + { + name: "no alias passes through", + argv: []string{"app", "list"}, + wantArgv: []string{"app", "list"}, + }, + { + name: "alias at position 0, app only", + argv: []string{"@my-app", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + }, + { + name: "alias at position 0, app and env", + argv: []string{"@my-app.staging", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "alias after subcommand", + argv: []string{"app", "list", "@my-app.staging"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "alias trailing after flags", + argv: []string{"something", "--argument=value", "@my-app"}, + wantArgv: []string{"something", "--argument=value"}, + wantApp: "my-app", + }, + { + name: "alias between subcommand and flag", + argv: []string{"app", "@my-app.staging", "--debug"}, + wantArgv: []string{"app", "--debug"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "token after `--` is not parsed", + argv: []string{"wp", "--", "@plugin", "activate"}, + wantArgv: []string{"wp", "--", "@plugin", "activate"}, + }, + { + name: "alias before `--` is parsed, tokens after are preserved", + argv: []string{"@my-app", "wp", "--", "@plugin", "activate"}, + wantArgv: []string{"wp", "--", "@plugin", "activate"}, + wantApp: "my-app", + }, + { + name: "mixed case is lowercased (Node parity)", + argv: []string{"@MyApp.Prod", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "myapp", + wantEnv: "prod", + }, + { + name: "instance-qualified env: three dotted segments", + argv: []string{"@app.env.instance", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "app", + wantEnv: "env.instance", + }, + { + name: "underscore in env name", + argv: []string{"@xxx.production_test", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "xxx", + wantEnv: "production_test", + }, + { + name: "numeric app slug", + argv: []string{"@1.env", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "1", + wantEnv: "env", + }, + { + name: "first alias consumed, second remains (Node parity)", + argv: []string{"@a", "app", "@b"}, + wantArgv: []string{"app", "@b"}, + wantApp: "a", + }, + { + name: "bare @ passes through (does not match isAlias)", + argv: []string{"@", "app"}, + wantArgv: []string{"@", "app"}, + }, + { + name: "@app. matches isAlias and parses with empty env", + argv: []string{"@app.", "list"}, + wantArgv: []string{"list"}, + wantApp: "app", + wantEnv: "", + }, + { + name: "@.env matches isAlias and parses with empty app", + argv: []string{"@.env", "list"}, + wantArgv: []string{"list"}, + wantApp: "", + wantEnv: "env", + }, + { + name: "empty argv", + argv: []string{}, + wantArgv: []string{}, + }, + { + name: "only --", + argv: []string{"--"}, + wantArgv: []string{"--"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotArgv, gotApp, gotEnv, err := Rewrite(tc.argv) + if err != nil { + t.Fatalf("Rewrite() unexpected err = %v", err) + } + if !reflect.DeepEqual(gotArgv, tc.wantArgv) { + t.Errorf("argv = %v, want %v", gotArgv, tc.wantArgv) + } + if gotApp != tc.wantApp { + t.Errorf("app = %q, want %q", gotApp, tc.wantApp) + } + if gotEnv != tc.wantEnv { + t.Errorf("env = %q, want %q", gotEnv, tc.wantEnv) + } + }) + } +} diff --git a/internal/envvar/envvar.go b/internal/envvar/envvar.go new file mode 100644 index 000000000..e2e4b17cc --- /dev/null +++ b/internal/envvar/envvar.go @@ -0,0 +1,253 @@ +// Package envvar wraps the GetEnvironmentVariables and +// GetEnvironmentVariablesWithValues genqlient operations behind a stable +// Go-friendly surface. +// +// The schema exposes only two operations — list names and list with values — +// so there is no server-side single-name fetch. Node's `vip config envvar get +// <NAME>` filters client-side from the get-all result; we mirror that. +// +// The two operations have distinct genqlient response types (different +// concrete types per query), so we walk both via reflection to a single +// flat slice. See Node parity sources: src/lib/envvar/api-list.ts, +// api-get.ts, api-get-all.ts. +package envvar + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "regexp" + "strings" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// NewRelicKey is the protected variable name. Node parity: +// src/bin/vip-config-envvar-set.js refuses to set it because the platform +// owns the value. Compared against the uppercased name (the handler +// uppercases before this check, mirroring Node). +const NewRelicKey = "NEW_RELIC_LICENSE_KEY" + +// validNameRe matches Node's effective `validateName` regex from +// src/lib/envvar/api.ts: trim+uppercase+strip non-[A-Z0-9_], then require +// the original to round-trip AND start with [A-Z]. Underscore-leading +// names are rejected (e.g. "_FOO") — Node parity. +var validNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// ErrInvalidName is the user-facing error returned by ValidateName. +// The message text matches Node's (modulo color) so parity scenarios can +// assert against substrings. +var ErrInvalidName = errors.New("Environment variable name must consist of A-Z, 0-9, or _,\nand must start with an uppercase letter.") + +// ValidateName returns nil if name matches Node's validation, otherwise +// ErrInvalidName. Empty is a special-case "name cannot be empty" error. +// Callers should uppercase + trim BEFORE calling — this function does not +// normalize on its own (matches Node where the uppercase is done in the +// command handler, then validateName runs against the result). +func ValidateName(name string) error { + if name == "" { + return errors.New("Environment variable name cannot be empty") + } + if !validNameRe.MatchString(name) { + return ErrInvalidName + } + return nil +} + +// Set adds or updates an environment variable. Node parity: api-set.ts +// calls ONLY addEnvironmentVariable — the server does upsert internally. +// We do the same. reloadManifest=false in current parity scenarios; the +// follow-up prompt is deferred (see config_envvar_set.go scope note). +func Set(ctx context.Context, c graphql.Client, appID, envID int64, name, value string, reloadManifest bool) error { + input := &gql.EnvironmentVariableInput{ + ApplicationId: appID, + EnvironmentId: envID, + Name: name, + Value: value, + ReloadManifest: &reloadManifest, + } + _, err := gql.AddEnvironmentVariable(ctx, c, input) + return err +} + +// Delete removes an environment variable. Node parity: api-delete.ts sends +// value: "" (empty string, NOT omitted) on the input — the schema marks +// Value as required even on delete. +func Delete(ctx context.Context, c graphql.Client, appID, envID int64, name string, reloadManifest bool) error { + input := &gql.EnvironmentVariableInput{ + ApplicationId: appID, + EnvironmentId: envID, + Name: name, + Value: "", + ReloadManifest: &reloadManifest, + } + _, err := gql.DeleteEnvironmentVariable(ctx, c, input) + return err +} + +// ReadFromFile reads the file at path and returns its content with +// leading + trailing whitespace stripped (Node parity: src/lib/read-file.ts +// does `data.trim()` — full TrimSpace, NOT just TrimRight). Internal +// whitespace is preserved. +func ReadFromFile(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + return strings.TrimSpace(string(b)), nil +} + +// EnvVar is a flat name/value pair. Empty Value distinguishes the +// list-of-names path from the with-values path at the type level. +type EnvVar struct { + Name string + Value string +} + +// List returns the names (only) of environment variables on the env. Empty +// slice is a valid result (no env vars set). Network/schema errors propagate. +func List(ctx context.Context, c graphql.Client, appID, envID int64) ([]string, error) { + resp, err := gql.GetEnvironmentVariables(ctx, c, appID, envID) + if err != nil { + return nil, err + } + nodes := walkEnvVarNodes(resp) + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + out = append(out, n.Name) + } + return out, nil +} + +// Get returns the EnvVar matching name, or nil if not present. Implements +// single-fetch client-side (the schema has no per-name query). Node parity: +// src/lib/envvar/api-get.ts. +func Get(ctx context.Context, c graphql.Client, appID, envID int64, name string) (*EnvVar, error) { + vars, err := GetAll(ctx, c, appID, envID) + if err != nil { + return nil, err + } + for i := range vars { + if vars[i].Name == name { + return &vars[i], nil + } + } + return nil, nil +} + +// GetAll returns every environment variable with its value. +func GetAll(ctx context.Context, c graphql.Client, appID, envID int64) ([]EnvVar, error) { + resp, err := gql.GetEnvironmentVariablesWithValues(ctx, c, appID, envID) + if err != nil { + return nil, err + } + nodes := walkEnvVarNodes(resp) + out := make([]EnvVar, 0, len(nodes)) + for _, n := range nodes { + out = append(out, EnvVar{Name: n.Name, Value: n.Value}) + } + return out, nil +} + +// envVarNode is a flat per-node view extracted via reflection. +type envVarNode struct { + Name string + Value string +} + +// walkEnvVarNodes accepts either GetEnvironmentVariablesResponse or +// GetEnvironmentVariablesWithValuesResponse (distinct genqlient concrete +// types) and yields a flat slice. The Value field is empty when the +// underlying response omits it (list-of-names query). +func walkEnvVarNodes(v any) []envVarNode { + out := []envVarNode{} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return out + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return out + } + // Navigate: resp.App -> envs[0] -> EnvironmentVariables -> Nodes + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return out + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return out + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return out + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return out + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return out + } + ev := env.FieldByName("EnvironmentVariables") + for ev.Kind() == reflect.Ptr { + if ev.IsNil() { + return out + } + ev = ev.Elem() + } + if !ev.IsValid() || ev.Kind() != reflect.Struct { + return out + } + nodes := ev.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return out + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item envVarNode + if f := n.FieldByName("Name"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Name = f.Elem().String() + } + case reflect.String: + item.Name = f.String() + } + } + if f := n.FieldByName("Value"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Value = f.Elem().String() + } + case reflect.String: + item.Value = f.String() + } + } + out = append(out, item) + } + return out +} diff --git a/internal/envvar/envvar_test.go b/internal/envvar/envvar_test.go new file mode 100644 index 000000000..6190f906d --- /dev/null +++ b/internal/envvar/envvar_test.go @@ -0,0 +1,274 @@ +package envvar + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// envvarServer returns a stub /graphql endpoint that responds with the given +// JSON body for every request. Sufficient for these tests because we drive +// each genqlient call in isolation. +func envvarServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestEnvvarListReturnsNames(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO"},{"name":"BAR"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + names, err := List(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(names) != 2 || names[0] != "FOO" || names[1] != "BAR" { + t.Errorf("names = %v, want [FOO BAR]", names) + } +} + +func TestEnvvarListEmpty(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":0,"nodes":[]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + names, err := List(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(names) != 0 { + t.Errorf("names = %v, want empty", names) + } +} + +func TestEnvvarGetFound(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO","value":"1"},{"name":"BAR","value":"two"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + ev, err := Get(context.Background(), c, 1, 2, "BAR") + if err != nil { + t.Fatalf("Get: %v", err) + } + if ev == nil || ev.Name != "BAR" || ev.Value != "two" { + t.Errorf("Get(BAR) = %+v, want {Name:BAR Value:two}", ev) + } +} + +func TestEnvvarGetNotFound(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"1"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + ev, err := Get(context.Background(), c, 1, 2, "MISSING") + if err != nil { + t.Fatalf("Get(MISSING) error: %v", err) + } + if ev != nil { + t.Errorf("Get(MISSING) = %+v, want nil", ev) + } +} + +func TestEnvvarGetAllReturnsValues(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"A","value":"1"},{"name":"B","value":"two"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + vars, err := GetAll(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(vars) != 2 { + t.Fatalf("vars len = %d, want 2; got=%+v", len(vars), vars) + } + if vars[0].Name != "A" || vars[0].Value != "1" { + t.Errorf("vars[0] = %+v, want {A 1}", vars[0]) + } + if vars[1].Name != "B" || vars[1].Value != "two" { + t.Errorf("vars[1] = %+v, want {B two}", vars[1]) + } +} + +func TestEnvvarGetAllNullValueIsEmptyString(t *testing.T) { + // Schema declares value as nullable: `value: String`. A null value should + // surface as an empty Go string rather than panic on a nil pointer. + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"NULLY","value":null}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + vars, err := GetAll(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(vars) != 1 || vars[0].Name != "NULLY" || vars[0].Value != "" { + t.Errorf("vars = %+v, want one {NULLY ''}", vars) + } +} + +// recordingServer is a multi-route stub that records the last request body. +// Used by Set / Delete tests to assert the wire-level mutation shape. +type recordingServer struct { + mu sync.Mutex + lastBody string + respBody string +} + +func (s *recordingServer) start() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.lastBody = string(body) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(s.respBody)) + })) +} + +func (s *recordingServer) body() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastBody +} + +func TestValidateName(t *testing.T) { + cases := []struct { + name string + wantErr bool + }{ + {"FOO", false}, + {"FOO_BAR", false}, + {"FOO123", false}, + {"F", false}, + {"A1_B2", false}, + // Empty: distinct error message. + {"", true}, + // Lowercase rejected. + {"foo", true}, + {"Foo", true}, + // Underscore-start rejected (Node parity). + {"_FOO", true}, + // Digit-start rejected. + {"1FOO", true}, + // Dash rejected. + {"FOO-BAR", true}, + // Space rejected. + {"FOO BAR", true}, + // Dot rejected. + {"FOO.BAR", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateName(tc.name) + if tc.wantErr && err == nil { + t.Errorf("ValidateName(%q) = nil, want error", tc.name) + } + if !tc.wantErr && err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", tc.name, err) + } + }) + } +} + +func TestValidateNameInvalidErrorMessage(t *testing.T) { + err := ValidateName("bad-name") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrInvalidName) { + t.Errorf("invalid-name path must return ErrInvalidName sentinel; got %v", err) + } + if !strings.Contains(err.Error(), "A-Z, 0-9, or _") { + t.Errorf("error message must include Node-parity hint; got %q", err.Error()) + } +} + +func TestSetSendsAddMutation(t *testing.T) { + rs := &recordingServer{ + respBody: `{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"FOO"}]}}}}`, + } + srv := rs.start() + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + if err := Set(context.Background(), c, 42, 7, "FOO", "hello", false); err != nil { + t.Fatalf("Set: %v", err) + } + body := rs.body() + if !strings.Contains(body, `"operationName":"AddEnvironmentVariable"`) { + t.Errorf("expected AddEnvironmentVariable operation; body=%s", body) + } + if !strings.Contains(body, `"name":"FOO"`) { + t.Errorf("expected name=FOO in input; body=%s", body) + } + if !strings.Contains(body, `"value":"hello"`) { + t.Errorf("expected value=hello in input; body=%s", body) + } + if !strings.Contains(body, `"applicationId":42`) { + t.Errorf("expected applicationId=42; body=%s", body) + } + if !strings.Contains(body, `"environmentId":7`) { + t.Errorf("expected environmentId=7; body=%s", body) + } +} + +func TestDeleteSendsDeleteMutationWithEmptyValue(t *testing.T) { + rs := &recordingServer{ + respBody: `{"data":{"deleteEnvironmentVariable":{"environmentVariables":{"total":0,"nodes":[]}}}}`, + } + srv := rs.start() + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + if err := Delete(context.Background(), c, 42, 7, "FOO", false); err != nil { + t.Fatalf("Delete: %v", err) + } + body := rs.body() + if !strings.Contains(body, `"operationName":"DeleteEnvironmentVariable"`) { + t.Errorf("expected DeleteEnvironmentVariable operation; body=%s", body) + } + // Node parity: delete sends value: "" — empty string, NOT omitted. + if !strings.Contains(body, `"value":""`) { + t.Errorf("delete must send empty-string value; body=%s", body) + } + if !strings.Contains(body, `"name":"FOO"`) { + t.Errorf("expected name=FOO in input; body=%s", body) + } +} + +func TestReadFromFileTrimsSurroundingWhitespace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "value.txt") + // Surrounding whitespace + a newline that Node's data.trim() would strip, + // and an internal newline that must survive. + content := "\n hello\nworld\n \n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write tmp file: %v", err) + } + got, err := ReadFromFile(path) + if err != nil { + t.Fatalf("ReadFromFile: %v", err) + } + want := "hello\nworld" + if got != want { + t.Errorf("ReadFromFile = %q, want %q", got, want) + } +} + +func TestReadFromFileMissing(t *testing.T) { + _, err := ReadFromFile(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} diff --git a/internal/envvar/reload_manifest.go b/internal/envvar/reload_manifest.go new file mode 100644 index 000000000..475d6a236 --- /dev/null +++ b/internal/envvar/reload_manifest.go @@ -0,0 +1,99 @@ +// Package envvar — reload-manifest UX helpers. +// +// These mirror Node's src/lib/envvar/input.ts surface: +// +// - promptForReloadManifest(appTypeId): yes/no Confirm asking whether to +// apply the envvar update now; prints a Node.js-specific build-vs-runtime +// warning for typeIds {3, 5, 7, 8}. +// - showDeployWarning(): yellow-bg "Important:" reminder printed after the +// mutation when the user declined the reload (or didn't get prompted). +// +// Node parity callers: src/bin/vip-config-envvar-set.js and +// src/bin/vip-config-envvar-delete.js wire these into the success path. +package envvar + +import ( + "fmt" + "io" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// NodeJsTypeIds mirrors src/lib/constants/vipgo.ts NODEJS_SITE_TYPE_IDS. +// Used to gate the Node.js-specific build-vs-runtime envvar warning. +var NodeJsTypeIds = map[int64]struct{}{3: {}, 5: {}, 7: {}, 8: {}} + +// isAppNodejs reports whether typeId belongs to NODEJS_SITE_TYPE_IDS. +// typeId == 0 (unknown / not populated) is treated as not-Node.js. +func isAppNodejs(typeId int64) bool { + _, ok := NodeJsTypeIds[typeId] + return ok +} + +// PromptForReloadManifest asks "Apply this environment variable update now?". +// Returns false (no prompt) on --skip-confirmation OR non-interactive. +// For Node.js apps, prefixes with the yellow build-vs-runtime warning. +// +// Node parity (src/lib/envvar/input.ts::promptForReloadManifest): +// - The Confirm prompt itself uses `.catch(() => false)`, so any error +// becomes "no". We mirror that: ErrNonInteractive (and any other prompt +// failure) falls through to false instead of erroring the command. +func PromptForReloadManifest(cmd *cobra.Command, typeId int64, skipConfirmation bool) (bool, error) { + if skipConfirmation { + return false, nil + } + if !appctx.IsInteractive(cmd) { + return false, nil + } + emitNodejsReloadWarning(cmd.OutOrStdout(), typeId) + ok, err := appctx.Confirm(cmd, "Apply this environment variable update now?", false) + if err != nil { + // Node parity: any prompt failure (incl. ErrNonInteractive) falls + // through as "no, don't reload" instead of erroring the command. + return false, nil + } + return ok, nil +} + +// emitNodejsReloadWarning prints the Node.js-specific build-vs-runtime +// notice. No-op for non-Node.js typeIds (or unknown typeId == 0). +// +// Node parity wording (input.ts): +// +// ⚠️ Note: Only applies to runtime variable changes. Build-time +// environment variable changes won't take effect until your next deploy. +// +// The whole line is yellow; "Only applies to runtime variable changes." +// is additionally bolded. +func emitNodejsReloadWarning(stdout io.Writer, typeId int64) { + if !isAppNodejs(typeId) { + return + } + // Inner span is bold-only; outer YellowString already paints the whole + // line yellow. Adding FgYellow inside would re-emit the yellow code + // inside an already-yellow span (Node uses chalk.bold inside chalk.yellow). + fmt.Fprintln(stdout, color.YellowString( + "⚠️ Note: %s Build-time environment variable changes won't take effect until your next deploy.", + color.New(color.Bold).Sprint("Only applies to runtime variable changes."), + )) +} + +// ShowDeployWarning prints the post-mutation "won't be available until the +// next deploy" reminder. Called by set/delete on the success path when +// reloadManifest=false AND not --skip-confirmation. Mirrors Node's +// showDeployWarning() in src/lib/envvar/input.ts: +// +// Important: This environment variable update will not be available +// until the next code deploy is made to this environment. +// +// "Important:" is bold + yellow background; the rest is plain. Node uses +// chalk.bgYellow(chalk.bold(...)), which leaves the foreground to the +// terminal's default — we match that by NOT forcing FgBlack/FgWhite. +func ShowDeployWarning(stdout io.Writer) { + fmt.Fprintf(stdout, "%s %s\n", + color.New(color.BgYellow, color.Bold).Sprint("Important:"), + "This environment variable update will not be available until the next code deploy is made to this environment.") +} diff --git a/internal/envvar/reload_manifest_test.go b/internal/envvar/reload_manifest_test.go new file mode 100644 index 000000000..5cf908ba8 --- /dev/null +++ b/internal/envvar/reload_manifest_test.go @@ -0,0 +1,44 @@ +package envvar + +import ( + "bytes" + "strings" + "testing" +) + +func TestPromptForReloadManifestNodejsTypeIdEmitsWarning(t *testing.T) { + var stdout bytes.Buffer + emitNodejsReloadWarning(&stdout, 3) + if !strings.Contains(stdout.String(), "Only applies to runtime variable changes") { + t.Errorf("Node.js typeId (3) should emit runtime/build-time warning; got %q", stdout.String()) + } +} + +func TestPromptForReloadManifestWordPressTypeIdSilent(t *testing.T) { + var stdout bytes.Buffer + emitNodejsReloadWarning(&stdout, 2) + if stdout.Len() != 0 { + t.Errorf("non-Node.js typeId must not emit warning; got %q", stdout.String()) + } +} + +func TestIsAppNodejs(t *testing.T) { + for _, id := range []int64{3, 5, 7, 8} { + if !isAppNodejs(id) { + t.Errorf("typeId %d must be Node.js", id) + } + } + for _, id := range []int64{0, 1, 2, 6, 99} { + if isAppNodejs(id) { + t.Errorf("typeId %d must NOT be Node.js", id) + } + } +} + +func TestShowDeployWarningIncludesImportantLabel(t *testing.T) { + var stdout bytes.Buffer + ShowDeployWarning(&stdout) + if !strings.Contains(stdout.String(), "Important:") || !strings.Contains(stdout.String(), "next code deploy") { + t.Errorf("ShowDeployWarning output missing expected text; got %q", stdout.String()) + } +} diff --git a/internal/envvar/value_confirm.go b/internal/envvar/value_confirm.go new file mode 100644 index 000000000..f18024103 --- /dev/null +++ b/internal/envvar/value_confirm.go @@ -0,0 +1,26 @@ +// Package envvar — value-echo confirm helper for vip config envvar set --from-file. +package envvar + +import ( + "fmt" + "io" +) + +// EchoValueForConfirm prints the read-from-file value between Node-parity +// banners so the user can confirm before the mutation fires. Called from +// runEnvvarSet when --from-file is used AND --skip-confirmation is NOT set. +// +// Output shape mirrors src/bin/vip-config-envvar-set.js exactly: +// +// ===== Received value printed below ===== +// <value verbatim> +// ===== Received value printed above ===== +// <blank line> +// +// Caller follows up with `appctx.Confirm(cmd, "Please confirm the input value above", false)`. +func EchoValueForConfirm(stdout io.Writer, value string) { + fmt.Fprintln(stdout, "===== Received value printed below =====") + fmt.Fprintln(stdout, value) + fmt.Fprintln(stdout, "===== Received value printed above =====") + fmt.Fprintln(stdout) +} diff --git a/internal/envvar/value_confirm_test.go b/internal/envvar/value_confirm_test.go new file mode 100644 index 000000000..857c30448 --- /dev/null +++ b/internal/envvar/value_confirm_test.go @@ -0,0 +1,22 @@ +package envvar + +import ( + "bytes" + "strings" + "testing" +) + +func TestEchoValueForConfirmBetweenBanners(t *testing.T) { + var stdout bytes.Buffer + EchoValueForConfirm(&stdout, "hello\nworld") + out := stdout.String() + if !strings.Contains(out, "===== Received value printed below =====") { + t.Errorf("missing opening banner; got %q", out) + } + if !strings.Contains(out, "===== Received value printed above =====") { + t.Errorf("missing closing banner; got %q", out) + } + if !strings.Contains(out, "hello\nworld") { + t.Errorf("value not echoed verbatim; got %q", out) + } +} diff --git a/internal/exit/exit.go b/internal/exit/exit.go new file mode 100644 index 000000000..5251c5005 --- /dev/null +++ b/internal/exit/exit.go @@ -0,0 +1,94 @@ +// Package exit owns process termination. +// +// WithError prints a user-facing error to stderr and exits 1. +// WithCode exits with a specific code (for parity with the Node binary's +// per-command exit conventions). +// +// RegisterErrorHook lets the telemetry layer record errors before exit; +// in M1 the hook is a no-op. M2 wires telemetry.TrackError into it. +package exit + +import ( + "errors" + "fmt" + "io" + "os" +) + +// alreadyPrinted marks an error whose user-facing message was deliberately +// written by the command itself. The process must still fail and telemetry +// must still observe it, but the shared exit path must not print it again. +type alreadyPrinted interface { + AlreadyPrinted() bool +} + +type handledError struct{ err error } + +func (e handledError) Error() string { return e.err.Error() } +func (e handledError) Unwrap() error { return e.err } +func (handledError) AlreadyPrinted() bool { return true } + +// Handled preserves a command error's non-zero exit while marking its message +// as already rendered for the user. +func Handled(err error) error { + if err == nil { + return nil + } + return handledError{err: err} +} + +type exitFunc func(int) +type errorHook func(error) + +// The package-level vars below are intentionally unsynchronized. +// RegisterErrorHook must be called once during single-threaded init, +// before any goroutine that may call WithError or WithCode is started. +// A signal handler that races with the main goroutine on these vars +// is unsupported in M1; M2 will revisit if needed. +var ( + stderr io.Writer = os.Stderr + exiter exitFunc = os.Exit + errHook errorHook = func(error) {} +) + +func WithError(err error) { + writeAndExit(stderr, exiter, errHook, err) +} + +func WithCode(code int, err error) { + writeAndExitCode(stderr, exiter, errHook, code, err) +} + +func RegisterErrorHook(h errorHook) { + if h == nil { + errHook = func(error) {} + return + } + errHook = h +} + +func writeAndExitCode(w io.Writer, ex exitFunc, hook errorHook, code int, err error) { + if err != nil { + hook(err) + if !isAlreadyPrinted(err) { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + } + } + ex(code) +} + +func writeAndExit(w io.Writer, ex exitFunc, hook errorHook, err error) { + if err == nil { + return + } + hook(err) + if !isAlreadyPrinted(err) { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + } + ex(1) +} + +func isAlreadyPrinted(err error) bool { + var marked alreadyPrinted + return errors.As(err, &marked) && marked.AlreadyPrinted() +} diff --git a/internal/exit/exit_test.go b/internal/exit/exit_test.go new file mode 100644 index 000000000..ae2284860 --- /dev/null +++ b/internal/exit/exit_test.go @@ -0,0 +1,119 @@ +package exit + +import ( + "bytes" + "errors" + "testing" +) + +func TestWriteErrorFormatsMessageAndCallsHook(t *testing.T) { + var buf bytes.Buffer + var calledCode int + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExit(&buf, exiter, hook, errors.New("boom")) + + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !hookCalled { + t.Error("hook was not called") + } + if got := buf.String(); got != "Error: boom\n" { + t.Errorf("stderr = %q, want %q", got, "Error: boom\n") + } +} + +func TestWriteErrorExitsWithoutDuplicatingAnAlreadyPrintedMessage(t *testing.T) { + var buf bytes.Buffer + calledCode := -1 + hookCalled := false + + writeAndExit( + &buf, + func(code int) { calledCode = code }, + func(error) { hookCalled = true }, + Handled(errors.New("message already shown on stdout")), + ) + + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !hookCalled { + t.Error("hook must still observe the failure") + } + if buf.Len() != 0 { + t.Errorf("already-printed error must not be duplicated on stderr; got %q", buf.String()) + } +} + +func TestWriteErrorNilErrorNoOps(t *testing.T) { + var buf bytes.Buffer + called := false + exiter := func(int) { called = true } + hook := func(error) {} + + writeAndExit(&buf, exiter, hook, nil) + + if called { + t.Error("exiter must not be called for nil error") + } + if buf.Len() != 0 { + t.Errorf("stderr should be empty, got %q", buf.String()) + } +} + +func TestRegisterHookReplaces(t *testing.T) { + original := errHook + t.Cleanup(func() { errHook = original }) + + called := 0 + RegisterErrorHook(func(error) { called++ }) + + errHook(errors.New("x")) + if called != 1 { + t.Errorf("hook called %d times, want 1", called) + } +} + +func TestWithCodeWithError(t *testing.T) { + var buf bytes.Buffer + var calledCode int + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExitCode(&buf, exiter, hook, 42, errors.New("specific failure")) + + if calledCode != 42 { + t.Errorf("exit code = %d, want 42", calledCode) + } + if !hookCalled { + t.Error("hook was not called when err != nil") + } + if got := buf.String(); got != "Error: specific failure\n" { + t.Errorf("stderr = %q, want %q", got, "Error: specific failure\n") + } +} + +func TestWithCodeNilErrorStillExits(t *testing.T) { + var buf bytes.Buffer + var calledCode int = -1 + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExitCode(&buf, exiter, hook, 0, nil) + + if calledCode != 0 { + t.Errorf("exit code = %d, want 0", calledCode) + } + if hookCalled { + t.Error("hook must not be called when err == nil") + } + if buf.Len() != 0 { + t.Errorf("stderr should be empty, got %q", buf.String()) + } +} diff --git a/internal/gql/SCHEMA.md b/internal/gql/SCHEMA.md new file mode 100644 index 000000000..2884990bf --- /dev/null +++ b/internal/gql/SCHEMA.md @@ -0,0 +1,21 @@ +# GraphQL schema vendoring + +`internal/gql/schema.gql` is vendored from the Node project's `schema.gql`, +which is itself generated by `npm run typescript:codegen:generate` against +the live VIP GraphQL API. + +## Refreshing the schema + +1. From the repo root: `npm install` and run `npm run typescript:codegen:generate`. + This requires a valid Node-CLI token (`vip login` if needed). +2. Copy the generated `schema.gql` to `internal/gql/schema.gql`. +3. Run `go generate ./internal/gql/...` to regenerate the typed client. +4. Run the full test suite + parity harness. +5. Commit the new `schema.gql` and the regenerated `generated.go` in one commit + with subject `chore(gql): refresh vendored schema and regenerated client`. + +## Why we vendor + +The schema is the source of truth for typed query generation. The Node project +generates it on demand and gitignores the result. The Go project pins a copy +so deterministic builds don't depend on having a live API endpoint or token. diff --git a/internal/gql/client.go b/internal/gql/client.go new file mode 100644 index 000000000..4d6557007 --- /dev/null +++ b/internal/gql/client.go @@ -0,0 +1,54 @@ +package gql + +import ( + "net/http" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Doer mirrors genqlient's interface plus what middleware needs. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Middleware wraps a Doer with a new Doer. +type Middleware func(next Doer) Doer + +// Config selects the GraphQL endpoint and per-environment behaviors. +type Config struct { + APIHost string // e.g. "https://api.wpvip.com" + TestMode bool // if true, skip the x_query rewrite (matches NODE_ENV=test) + Token string // bearer token (set by callers; auth package supplies) + HTTPClient *http.Client + Middleware []Middleware // outermost first + ExitOnError bool // honored by Error middleware (Task 5) + SilenceAuth bool // honored by Error middleware (Task 5) +} + +// Client composes a transport with middleware. It implements Doer. +type Client struct { + chain Doer + cfg Config +} + +func NewClient(cfg Config) *Client { + if cfg.HTTPClient == nil { + // NOT http.DefaultClient: its proxy policy is the inverse of Node's + // (see internal/httpproxy). This client carries the bearer token. + cfg.HTTPClient = httpproxy.Client() + } + base := newTransport(cfg) + chain := Doer(base) + for i := len(cfg.Middleware) - 1; i >= 0; i-- { + chain = cfg.Middleware[i](chain) + } + return &Client{chain: chain, cfg: cfg} +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + return c.chain.Do(req) +} + +// APIHost returns the configured API host. Used by callers (e.g. defensivemode) +// that need to POST directly to /graphql. +func (c *Client) APIHost() string { return c.cfg.APIHost } diff --git a/internal/gql/client_test.go b/internal/gql/client_test.go new file mode 100644 index 000000000..8636e59c8 --- /dev/null +++ b/internal/gql/client_test.go @@ -0,0 +1,71 @@ +package gql + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClientPostsToGraphQLEndpointWithXQuery(t *testing.T) { + var got struct { + path string + query string + body string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.path = r.URL.Path + got.query = r.URL.RawQuery + b := make([]byte, 4096) + n, _ := r.Body.Read(b) + got.body = string(b[:n]) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + + c := NewClient(Config{APIHost: srv.URL}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"query":"query Me{me{id}}","operationName":"Me"}`)) + req.Header.Set("Content-Type", "application/json") + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if got.path != "/graphql" { + t.Errorf("path = %q, want /graphql", got.path) + } + if !strings.HasPrefix(got.query, "x_query=Me") { + t.Errorf("query = %q, want prefix x_query=Me", got.query) + } +} + +func TestClientSkipsXQueryInTestEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + t.Errorf("test-mode client must not append x_query; got %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + c := NewClient(Config{APIHost: srv.URL, TestMode: true}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"query":"{me{id}}","operationName":"Me"}`)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } +} + +func TestClientSetsAuthHeaderWhenTokenPresent(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + c := NewClient(Config{APIHost: srv.URL, TestMode: true, Token: "abc.def.ghi"}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"{me{id}}"}`)) + c.Do(req) + if got != "Bearer abc.def.ghi" { + t.Errorf("Authorization = %q, want Bearer abc.def.ghi", got) + } +} diff --git a/internal/gql/decoder_compat_helpers_test.go b/internal/gql/decoder_compat_helpers_test.go new file mode 100644 index 000000000..76d13da86 --- /dev/null +++ b/internal/gql/decoder_compat_helpers_test.go @@ -0,0 +1,35 @@ +// Package-level helpers shared by decoder_compat_test.go. +package gql + +import ( + "fmt" + "reflect" +) + +// reflectedField is a tiny shape used by the forward-compat audit test. +type reflectedField struct { + Name string + Type string +} + +// reflectExportedFields enumerates exported fields of v (which may be a +// struct value or pointer to one). Used to enforce the "all generated +// optional fields must be pointers" invariant from genqlient.yaml. +func reflectExportedFields(v any) []reflectedField { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + out := make([]reflectedField, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + out = append(out, reflectedField{Name: f.Name, Type: fmt.Sprintf("%v", f.Type)}) + } + return out +} diff --git a/internal/gql/decoder_compat_test.go b/internal/gql/decoder_compat_test.go new file mode 100644 index 000000000..9a598e7d2 --- /dev/null +++ b/internal/gql/decoder_compat_test.go @@ -0,0 +1,78 @@ +package gql + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + json "encoding/json/v2" +) + +func TestDecodeIgnoresUnknownTopLevelFields(t *testing.T) { + type known struct { + ID *int64 `json:"id"` + Name *string `json:"name"` + } + payload := []byte(`{"id":42,"name":"x","newField":"surprise","anotherNew":{"nested":1}}`) + var k known + if err := json.Unmarshal(payload, &k); err != nil { + t.Fatalf("decode failed on extra fields — forward-compat lost: %v", err) + } + if k.ID == nil || *k.ID != 42 || k.Name == nil || *k.Name != "x" { + t.Errorf("decoded values wrong: %+v", k) + } +} + +func TestDecodeAcceptsNullForOptionalFields(t *testing.T) { + type opt struct { + Name *string `json:"name"` + } + var o opt + if err := json.Unmarshal([]byte(`{"name":null}`), &o); err != nil { + t.Fatalf("decode failed on null: %v", err) + } + if o.Name != nil { + t.Errorf("null should yield nil pointer; got %v", *o.Name) + } +} + +func TestGenqlientResponseIgnoresUnknownFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"me":{"id":7,"displayName":"x","isVIP":true,"newServerOnlyField":"surprise"}}}`)) + })) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", http.DefaultClient) + res, err := Me(t.Context(), c) + if err != nil { + t.Fatalf("Me: %v", err) + } + if res.Me == nil || res.Me.Id == nil || *res.Me.Id != 7 { + t.Errorf("decoded Me wrong: %+v", res.Me) + } +} + +func TestAuditNoNonPointerOptionals(t *testing.T) { + type checked struct { + typ string + read func() any + } + cases := []checked{ + {typ: "MeMe", read: func() any { return MeMe{} }}, + } + for _, c := range cases { + v := c.read() + fields := reflectExportedFields(v) + for _, f := range fields { + if strings.HasPrefix(f.Name, "GetType") || f.Name == "Typename" { + continue + } + if !strings.HasPrefix(f.Type, "*") && !strings.HasPrefix(f.Type, "[]") { + t.Errorf("%s.%s is %s (expected pointer/slice for forward-compat tolerance)", c.typ, f.Name, f.Type) + } + } + } +} diff --git a/internal/gql/doc.go b/internal/gql/doc.go new file mode 100644 index 000000000..4d8908904 --- /dev/null +++ b/internal/gql/doc.go @@ -0,0 +1,13 @@ +// Package gql is the typed GraphQL client for the vip-cli Go rewrite. +// +// The schema is vendored from the Node project (see SCHEMA.md). Operations +// live in operations/*.graphql. Run `go generate ./internal/gql/...` to +// regenerate the typed client into generated.go. +// +// The client is composed of stacked middleware (transport -> retry -> +// rechallenge -> error-handling, applied outermost-first). Each middleware +// is a Doer that wraps a next Doer. The rechallenge slot is a no-op in +// M2; M3 will fill it in with the step-up flow per project_rechallenge_v2.md. +package gql + +//go:generate genqlient diff --git a/internal/gql/error.go b/internal/gql/error.go new file mode 100644 index 000000000..6121849ea --- /dev/null +++ b/internal/gql/error.go @@ -0,0 +1,137 @@ +package gql + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + + json "encoding/json/v2" +) + +// ErrorConfig controls the behavior of the error middleware. +type ErrorConfig struct { + Stderr io.Writer + Exit func(int) + Silence bool // mirrors silenceAuthErrors + ExitOnError bool // mirrors exitOnError +} + +// ctxKeyAllowGQLErrors is a context key that, when set to a true bool, +// instructs the error middleware to skip its print + exit behavior for +// the request's response so the caller can inspect the GraphQL errors +// inline (e.g. vip sync handles "Site is already syncing" specially). +type ctxKeyAllowGQLErrors struct{} + +// WithAllowGQLErrors returns a child context that disables the error +// middleware's print + exit-on-error behavior for any GraphQL request +// issued with this context (or one derived from it). Network/401 paths +// are unaffected — only the GraphQL errors[] check is bypassed. +func WithAllowGQLErrors(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeyAllowGQLErrors{}, true) +} + +// allowGQLErrorsFromContext reports whether the request's context opts +// out of the error middleware's GraphQL-error handling. +func allowGQLErrorsFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(ctxKeyAllowGQLErrors{}).(bool) + return v +} + +// NewErrorMiddleware returns a Middleware that: +// - On HTTP 401 (and !Silence): prints "Unauthorized: <message>" to Stderr and calls Exit(1). +// - On GraphQL errors: prints "Error: <message>" for each error; calls Exit(1) if ExitOnError. +// +// Message wording is exact Node parity with src/lib/api.ts errorLink. +func NewErrorMiddleware(cfg ErrorConfig) Middleware { + if cfg.Stderr == nil { + cfg.Stderr = os.Stderr + } + if cfg.Exit == nil { + cfg.Exit = os.Exit + } + return func(next Doer) Doer { return &errorDoer{next: next, cfg: cfg} } +} + +type errorDoer struct { + next Doer + cfg ErrorConfig +} + +func (e *errorDoer) Do(req *http.Request) (*http.Response, error) { + resp, err := e.next.Do(req) + if err != nil || resp == nil { + return resp, err + } + + if resp.StatusCode == 401 && !e.cfg.Silence { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + msg := decode401Message(body) + fmt.Fprintf(e.cfg.Stderr, "Unauthorized: %s\n", msg) + e.cfg.Exit(1) + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil + } + + // Peek body for GraphQL errors. + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + + if hasGraphQLErrors(body) && !allowGQLErrorsFromContext(req.Context()) { + for _, m := range extractGraphQLErrorMessages(body) { + fmt.Fprintf(e.cfg.Stderr, "Error: %s\n", m) + } + if e.cfg.ExitOnError { + e.cfg.Exit(1) + } + } + return resp, nil +} + +func decode401Message(body []byte) string { + const inactivity = "Your token has expired due to inactivity" + const defaultMsg = "You are not authorized to perform this request" + const suffix = "; please log out with `vip logout`, then try again." + if len(body) > 0 { + var doc struct { + Code string `json:"code"` + } + if err := json.Unmarshal(body, &doc); err == nil && doc.Code == "token-disabled-inactivity" { + return inactivity + suffix + } + } + return defaultMsg + suffix +} + +func hasGraphQLErrors(body []byte) bool { + var doc struct { + Errors []map[string]any `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return false + } + return len(doc.Errors) > 0 +} + +func extractGraphQLErrorMessages(body []byte) []string { + var doc struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil + } + out := make([]string, 0, len(doc.Errors)) + for _, e := range doc.Errors { + out = append(out, e.Message) + } + return out +} diff --git a/internal/gql/error_test.go b/internal/gql/error_test.go new file mode 100644 index 000000000..81ef470ae --- /dev/null +++ b/internal/gql/error_test.go @@ -0,0 +1,203 @@ +package gql + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestErrorMiddleware401InactivityMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"token-disabled-inactivity","message":"x"}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "Your token has expired due to inactivity") { + t.Errorf("stderr missing inactivity message: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "please log out with `vip logout`") { + t.Errorf("stderr missing logout suffix: %q", stderr.String()) + } +} + +func TestErrorMiddleware401DefaultMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`not-json`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "You are not authorized to perform this request") { + t.Errorf("stderr missing default message: %q", stderr.String()) + } +} + +func TestErrorMiddleware401Silenced(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"x"}`)) + })) + defer srv.Close() + called := false + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Silence: true, Exit: func(int) { called = true }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if called { + t.Error("exiter must not be called when Silence is true") + } + if resp.StatusCode != 401 { + t.Errorf("response status = %d, want 401", resp.StatusCode) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func TestErrorMiddlewareGraphQLErrorsExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"data":null,"errors":[{"message":"App not found"}]}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + ExitOnError: true, Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "Error:") || !strings.Contains(stderr.String(), "App not found") { + t.Errorf("stderr missing GraphQL error: %q", stderr.String()) + } +} + +func TestErrorMiddlewareGraphQLErrorsNoExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"errors":[{"message":"oops"}]}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + ExitOnError: false, Exit: func(int) { t.Error("must not exit when ExitOnError is false") }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"errors"`) { + t.Errorf("response body should still contain errors when not exiting: %s", body) + } +} + +// TestErrorMiddlewareWithAllowGQLErrorsSuppressesPrintAndExit pins the +// opt-out contract for WithAllowGQLErrors: a request whose context has the +// flag set must NOT print "Error:" to stderr AND must NOT call Exit on a +// GraphQL errors[] response. The response body remains readable so the +// caller (e.g. sync.Start) can inspect the errors[] inline. +func TestErrorMiddlewareWithAllowGQLErrorsSuppressesPrintAndExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"errors":[{"message":"Site is already syncing"}]}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + exitCalled := false + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, ExitOnError: true, Exit: func(int) { exitCalled = true }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Sync","query":"mutation{x}"}`)) + req = req.WithContext(WithAllowGQLErrors(req.Context())) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if exitCalled { + t.Error("Exit must NOT be called when WithAllowGQLErrors is on context") + } + if stderr.Len() != 0 { + t.Errorf("stderr must be empty when opted out; got %q", stderr.String()) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Site is already syncing") { + t.Errorf("body must still be readable downstream; got %q", body) + } +} + +// TestErrorMiddlewareWithAllowGQLErrorsDoesNotAffect401 verifies the +// documented promise that the opt-out covers only GraphQL errors[], +// NOT the 401 path. A 401 response with WithAllowGQLErrors set must still +// print "Unauthorized:" and call Exit(1). Regression guard so a future +// refactor of error.go can't silently widen the opt-out's scope. +func TestErrorMiddlewareWithAllowGQLErrorsDoesNotAffect401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"token-disabled-inactivity"}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + req = req.WithContext(WithAllowGQLErrors(req.Context())) + c.Do(req) + if calledCode != 1 { + t.Errorf("401 must still exit(1) even with WithAllowGQLErrors; got %d", calledCode) + } + if !strings.Contains(stderr.String(), "Unauthorized:") { + t.Errorf("401 must still print 'Unauthorized:'; got %q", stderr.String()) + } +} diff --git a/internal/gql/generated.go b/internal/gql/generated.go new file mode 100644 index 000000000..e7ace8cd5 --- /dev/null +++ b/internal/gql/generated.go @@ -0,0 +1,8935 @@ +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +package gql + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Khan/genqlient/graphql" +) + +// AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload includes the requested fields of the GraphQL type AppEnvironmentAbortMediaImportPayload. +// The GraphQL type's documentation follows. +// +// Response payload for aborting a Media Import +type AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload struct { + // The unique ID of the Application + ApplicationId *int64 `json:"applicationId"` + // The unique ID of the Environment + EnvironmentId *int64 `json:"environmentId"` + // Media Import Abort Action Response + MediaImportStatusChange *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange `json:"mediaImportStatusChange"` +} + +// GetApplicationId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.ApplicationId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetApplicationId() *int64 { + return v.ApplicationId +} + +// GetEnvironmentId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetMediaImportStatusChange returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.MediaImportStatusChange, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetMediaImportStatusChange() *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange { + return v.MediaImportStatusChange +} + +// AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatusChange. +// The GraphQL type's documentation follows. +// +// Response payload for executing a status change action on a Media Import +type AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The status of Media Import prior to status change action + StatusFrom *string `json:"statusFrom"` + // The status of Media Import after the status change action + StatusTo *string `json:"statusTo"` +} + +// GetImportId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.ImportId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.SiteId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatusFrom returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.StatusFrom, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetStatusFrom() *string { + return v.StatusFrom +} + +// GetStatusTo returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.StatusTo, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetStatusTo() *string { + return v.StatusTo +} + +// AbortMediaImportResponse is returned by AbortMediaImport on success. +type AbortMediaImportResponse struct { + // Abort a media import. + AbortMediaImport *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload `json:"abortMediaImport"` +} + +// GetAbortMediaImport returns AbortMediaImportResponse.AbortMediaImport, and is useful for accessing the field via an interface. +func (v *AbortMediaImportResponse) GetAbortMediaImport() *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload { + return v.AbortMediaImport +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload includes the requested fields of the GraphQL type EnvironmentVariablesPayload. +// The GraphQL type's documentation follows. +// +// The updated environment variable list after a mutation. +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload struct { + // The environment variables currently configured on the environment. + EnvironmentVariables *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetEnvironmentVariables returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload) GetEnvironmentVariables() *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// AddEnvironmentVariableResponse is returned by AddEnvironmentVariable on success. +type AddEnvironmentVariableResponse struct { + // Add an environment variable to an application environment. + AddEnvironmentVariable *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload `json:"addEnvironmentVariable"` +} + +// GetAddEnvironmentVariable returns AddEnvironmentVariableResponse.AddEnvironmentVariable, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableResponse) GetAddEnvironmentVariable() *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload { + return v.AddEnvironmentVariable +} + +// AppBackupAndJobStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBackupAndJobStatusApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppBackupAndJobStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppBackupAndJobStatusApp.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns AppBackupAndJobStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusApp) GetEnvironments() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The SQL dump tool used for backups. + BackupsSqlDumpTool *string `json:"backupsSqlDumpTool"` + // The most recent backup for the environment. + LatestBackup *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup `json:"latestBackup"` + // Jobs running on or related to the environment. + Jobs []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetBackupsSqlDumpTool returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.BackupsSqlDumpTool, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetBackupsSqlDumpTool() *string { + return v.BackupsSqlDumpTool +} + +// GetLatestBackup returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.LatestBackup, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetLatestBackup() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup { + return v.LatestBackup +} + +// GetJobs returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetJobs() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppBackupAndJobStatusAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.AppBackupAndJobStatusAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + BackupsSqlDumpTool *string `json:"backupsSqlDumpTool"` + + LatestBackup *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup `json:"latestBackup"` + + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment, error) { + var retval __premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment + + retval.Id = v.Id + retval.BackupsSqlDumpTool = v.BackupsSqlDumpTool + retval.LatestBackup = v.LatestBackup + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetMetadata returns the interface-field "metadata" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Additional metadata for the job. + GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata includes the requested fields of the GraphQL type JobMetadata. +// The GraphQL type's documentation follows. +// +// A metadata entry attached to a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata struct { + // The metadata key. + Name *string `json:"name"` + // The metadata value. + Value *string `json:"value"` +} + +// GetName returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Name, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetName() *string { + return v.Name +} + +// GetValue returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Value, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetValue() *string { + return v.Value +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The unique identifier for the step. + Id *string `json:"id"` + // The display name of the step. + Name *string `json:"name"` + // The step key. + Step *string `json:"step"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetId() *string { + return v.Id +} + +// GetName returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStep returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Step, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStep() *string { + return v.Step +} + +// GetStatus returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup includes the requested fields of the GraphQL type Backup. +// The GraphQL type's documentation follows. +// +// A backup available for an environment. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup struct { + // The unique identifier for the backup. + Id *float64 `json:"id"` + // The backup type. + Type *string `json:"type"` + // The backup size in bytes. + Size *float64 `json:"size"` + // The backup filename. + Filename *string `json:"filename"` + // The SQL dump tool used to generate the backup. + SqlDumpTool *string `json:"sqlDumpTool"` + // When the backup was created. + CreatedAt *string `json:"createdAt"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetId() *float64 { + return v.Id +} + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetType() *string { + return v.Type +} + +// GetSize returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Size, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetSize() *float64 { + return v.Size +} + +// GetFilename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Filename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetFilename() *string { + return v.Filename +} + +// GetSqlDumpTool returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.SqlDumpTool, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetSqlDumpTool() *string { + return v.SqlDumpTool +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetCreatedAt() *string { + return v.CreatedAt +} + +// AppBackupAndJobStatusResponse is returned by AppBackupAndJobStatus on success. +type AppBackupAndJobStatusResponse struct { + // Retrieve a single application. + App *AppBackupAndJobStatusApp `json:"app"` +} + +// GetApp returns AppBackupAndJobStatusResponse.App, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusResponse) GetApp() *AppBackupAndJobStatusApp { return v.App } + +// AppBackupJobStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBackupJobStatusApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppBackupJobStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppBackupJobStatusApp.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns AppBackupJobStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusApp) GetEnvironments() []*AppBackupJobStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppBackupJobStatusAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Jobs running on or related to the environment. + Jobs []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetJobs returns AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) GetJobs() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppBackupJobStatusAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.AppBackupJobStatusAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment, error) { + var retval __premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment + + retval.Id = v.Id + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetMetadata returns the interface-field "metadata" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Additional metadata for the job. + GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata includes the requested fields of the GraphQL type JobMetadata. +// The GraphQL type's documentation follows. +// +// A metadata entry attached to a job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata struct { + // The metadata key. + Name *string `json:"name"` + // The metadata value. + Value *string `json:"value"` +} + +// GetName returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Name, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetName() *string { + return v.Name +} + +// GetValue returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Value, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetValue() *string { + return v.Value +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` +} + +// GetStatus returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupJobStatusResponse is returned by AppBackupJobStatus on success. +type AppBackupJobStatusResponse struct { + // Retrieve a single application. + App *AppBackupJobStatusApp `json:"app"` +} + +// GetApp returns AppBackupJobStatusResponse.App, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusResponse) GetApp() *AppBackupJobStatusApp { return v.App } + +// AppBasic includes the GraphQL fields of App requested by the fragment AppBasic. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBasic struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` +} + +// GetId returns AppBasic.Id, and is useful for accessing the field via an interface. +func (v *AppBasic) GetId() *int64 { return v.Id } + +// GetName returns AppBasic.Name, and is useful for accessing the field via an interface. +func (v *AppBasic) GetName() *string { return v.Name } + +// GetRepo returns AppBasic.Repo, and is useful for accessing the field via an interface. +func (v *AppBasic) GetRepo() *string { return v.Repo } + +// Mutation request input to abort a Media Import +type AppEnvironmentAbortMediaImportInput struct { + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // The uniqueID of the Environment + EnvironmentId int64 `json:"environmentId"` +} + +// GetApplicationId returns AppEnvironmentAbortMediaImportInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentAbortMediaImportInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetEnvironmentId returns AppEnvironmentAbortMediaImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentAbortMediaImportInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// Input for starting a custom deploy. +type AppEnvironmentCustomDeployInput struct { + // The application ID, when required by the caller. + Id *int64 `json:"id"` + // The environment ID to deploy to. + EnvironmentId *int64 `json:"environmentId"` + // The deployment artifact filename. + Basename *string `json:"basename"` + // The checksum of the deployment artifact. + Checksum *string `json:"checksum"` + // The deploy message to record. + DeployMessage *string `json:"deployMessage"` +} + +// GetId returns AppEnvironmentCustomDeployInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetId() *int64 { return v.Id } + +// GetEnvironmentId returns AppEnvironmentCustomDeployInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetBasename returns AppEnvironmentCustomDeployInput.Basename, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetBasename() *string { return v.Basename } + +// GetChecksum returns AppEnvironmentCustomDeployInput.Checksum, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetChecksum() *string { return v.Checksum } + +// GetDeployMessage returns AppEnvironmentCustomDeployInput.DeployMessage, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetDeployMessage() *string { return v.DeployMessage } + +// Input for updating defensive mode configuration. +type AppEnvironmentDefensiveModeConfigInput struct { + // The challenge type to apply. + ChallengeType int64 `json:"challengeType"` + // The absolute connection threshold that triggers defensive mode. + ConnectionThresholdAbsolute *int64 `json:"connectionThresholdAbsolute"` + // The connection threshold percentage that triggers defensive mode. + ConnectionThresholdPercentage *int64 `json:"connectionThresholdPercentage"` + // Whether defensive mode should be enabled. + Enabled bool `json:"enabled"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetChallengeType returns AppEnvironmentDefensiveModeConfigInput.ChallengeType, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetChallengeType() int64 { return v.ChallengeType } + +// GetConnectionThresholdAbsolute returns AppEnvironmentDefensiveModeConfigInput.ConnectionThresholdAbsolute, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetConnectionThresholdAbsolute() *int64 { + return v.ConnectionThresholdAbsolute +} + +// GetConnectionThresholdPercentage returns AppEnvironmentDefensiveModeConfigInput.ConnectionThresholdPercentage, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetConnectionThresholdPercentage() *int64 { + return v.ConnectionThresholdPercentage +} + +// GetEnabled returns AppEnvironmentDefensiveModeConfigInput.Enabled, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetEnabled() bool { return v.Enabled } + +// GetEnvironmentId returns AppEnvironmentDefensiveModeConfigInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentDefensiveModeConfigInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetId() int64 { return v.Id } + +// Input for enabling or disabling defensive mode. +type AppEnvironmentDefensiveModeUpdateStatusInput struct { + // Whether defensive mode should be enabled. + Enabled bool `json:"enabled"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetEnabled returns AppEnvironmentDefensiveModeUpdateStatusInput.Enabled, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetEnabled() bool { return v.Enabled } + +// GetEnvironmentId returns AppEnvironmentDefensiveModeUpdateStatusInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetEnvironmentId() int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentDefensiveModeUpdateStatusInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetId() int64 { return v.Id } + +// Input for generating a database backup copy download URL. +type AppEnvironmentGenerateDBBackupCopyUrlInput struct { + // The backup ID to generate a URL for. + BackupId *float64 `json:"backupId"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` +} + +// GetBackupId returns AppEnvironmentGenerateDBBackupCopyUrlInput.BackupId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetBackupId() *float64 { return v.BackupId } + +// GetEnvironmentId returns AppEnvironmentGenerateDBBackupCopyUrlInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentGenerateDBBackupCopyUrlInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetId() *int64 { return v.Id } + +// Input for starting an environment import. +type AppEnvironmentImportInput struct { + // The backup basename to import. + Basename *string `json:"basename"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` + // The expected MD5 checksum. + Md5 *string `json:"md5"` + // The search-and-replace rules to apply. + SearchReplace []*AppEnvironmentImportSearchReplace `json:"searchReplace"` + // Whether to skip creating a backup before import. + SkipBackup *bool `json:"skipBackup"` + // Whether to skip maintenance mode during import. + SkipMaintenanceMode *bool `json:"skipMaintenanceMode"` + // The source URL to import from. + Url *string `json:"url"` + // The request headers to include when fetching the source URL. + UrlHeaders []*RequestHeader `json:"urlHeaders"` +} + +// GetBasename returns AppEnvironmentImportInput.Basename, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetBasename() *string { return v.Basename } + +// GetEnvironmentId returns AppEnvironmentImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentImportInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetId() *int64 { return v.Id } + +// GetMd5 returns AppEnvironmentImportInput.Md5, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetMd5() *string { return v.Md5 } + +// GetSearchReplace returns AppEnvironmentImportInput.SearchReplace, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSearchReplace() []*AppEnvironmentImportSearchReplace { + return v.SearchReplace +} + +// GetSkipBackup returns AppEnvironmentImportInput.SkipBackup, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSkipBackup() *bool { return v.SkipBackup } + +// GetSkipMaintenanceMode returns AppEnvironmentImportInput.SkipMaintenanceMode, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSkipMaintenanceMode() *bool { return v.SkipMaintenanceMode } + +// GetUrl returns AppEnvironmentImportInput.Url, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetUrl() *string { return v.Url } + +// GetUrlHeaders returns AppEnvironmentImportInput.UrlHeaders, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetUrlHeaders() []*RequestHeader { return v.UrlHeaders } + +// A search-and-replace rule applied during import. +type AppEnvironmentImportSearchReplace struct { + // The source string to replace. + From *string `json:"from"` + // The replacement string. + To *string `json:"to,omitempty"` +} + +// GetFrom returns AppEnvironmentImportSearchReplace.From, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportSearchReplace) GetFrom() *string { return v.From } + +// GetTo returns AppEnvironmentImportSearchReplace.To, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportSearchReplace) GetTo() *string { return v.To } + +// Input for generating a live backup copy download URL. +type AppEnvironmentLiveBackupCopyDownloadURLInput struct { + // The live backup copy ID. + CopyId string `json:"copyId"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetCopyId returns AppEnvironmentLiveBackupCopyDownloadURLInput.CopyId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetCopyId() string { return v.CopyId } + +// GetEnvironmentId returns AppEnvironmentLiveBackupCopyDownloadURLInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetEnvironmentId() int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentLiveBackupCopyDownloadURLInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetId() int64 { return v.Id } + +// The available environment log streams. +type AppEnvironmentLogType string + +const ( + // Application logs (`type: app`). + AppEnvironmentLogTypeApp AppEnvironmentLogType = "app" + // Batch job logs (`type: batch`). + AppEnvironmentLogTypeBatch AppEnvironmentLogType = "batch" +) + +var AllAppEnvironmentLogType = []AppEnvironmentLogType{ + AppEnvironmentLogTypeApp, + AppEnvironmentLogTypeBatch, +} + +// Input for starting a database backup copy. +type AppEnvironmentStartDBBackupCopyInput struct { + // The backup ID to copy. + BackupId *float64 `json:"backupId"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` + // The subsite ID to target, when applicable. + SubsiteId *int64 `json:"subsiteId"` + // The tables to include in the copy. + Tables []*string `json:"tables"` +} + +// GetBackupId returns AppEnvironmentStartDBBackupCopyInput.BackupId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetBackupId() *float64 { return v.BackupId } + +// GetEnvironmentId returns AppEnvironmentStartDBBackupCopyInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentStartDBBackupCopyInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetId() *int64 { return v.Id } + +// GetSubsiteId returns AppEnvironmentStartDBBackupCopyInput.SubsiteId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetSubsiteId() *int64 { return v.SubsiteId } + +// GetTables returns AppEnvironmentStartDBBackupCopyInput.Tables, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetTables() []*string { return v.Tables } + +// Mutation request input to start a Media Import +type AppEnvironmentStartMediaImportInput struct { + // API version to be used for the media import + ApiVersion *string `json:"apiVersion"` + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // Publicly accessible URL that contains an archive of the media files to be imported + ArchiveUrl string `json:"archiveUrl"` + // The uniqueID of the Environment + EnvironmentId int64 `json:"environmentId"` + // Whether to import intermediate images or not + ImportIntermediateImages *bool `json:"importIntermediateImages"` + // Whether to overwrite existing files or not + OverwriteExistingFiles *bool `json:"overwriteExistingFiles"` +} + +// GetApiVersion returns AppEnvironmentStartMediaImportInput.ApiVersion, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetApiVersion() *string { return v.ApiVersion } + +// GetApplicationId returns AppEnvironmentStartMediaImportInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetArchiveUrl returns AppEnvironmentStartMediaImportInput.ArchiveUrl, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetArchiveUrl() string { return v.ArchiveUrl } + +// GetEnvironmentId returns AppEnvironmentStartMediaImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetImportIntermediateImages returns AppEnvironmentStartMediaImportInput.ImportIntermediateImages, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetImportIntermediateImages() *bool { + return v.ImportIntermediateImages +} + +// GetOverwriteExistingFiles returns AppEnvironmentStartMediaImportInput.OverwriteExistingFiles, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetOverwriteExistingFiles() *bool { + return v.OverwriteExistingFiles +} + +// Input for triggering an environment sync. +type AppEnvironmentSyncInput struct { + // The copy configuration payload. + Config *json.RawMessage `json:"config"` + // The environment ID to sync. + EnvironmentId int64 `json:"environmentId"` + // The source environment ID to sync from. + FromEnvironmentId *int64 `json:"fromEnvironmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetConfig returns AppEnvironmentSyncInput.Config, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetConfig() *json.RawMessage { return v.Config } + +// GetEnvironmentId returns AppEnvironmentSyncInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetFromEnvironmentId returns AppEnvironmentSyncInput.FromEnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetFromEnvironmentId() *int64 { return v.FromEnvironmentId } + +// GetId returns AppEnvironmentSyncInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetId() int64 { return v.Id } + +// Input for triggering a database backup. +type AppEnvironmentTriggerDBBackupInput struct { + // Whether to perform a dry run. + DryRun *bool `json:"dryRun"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetDryRun returns AppEnvironmentTriggerDBBackupInput.DryRun, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetDryRun() *bool { return v.DryRun } + +// GetEnvironmentId returns AppEnvironmentTriggerDBBackupInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentTriggerDBBackupInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetId() int64 { return v.Id } + +// Variables for the Run WP-CLI Command mutation +type AppEnvironmentTriggerWPCLICommandInput struct { + // The command we want to run. Note: should not include 'wp' + Command *string `json:"command"` + // The environment ID where we want to run the command + EnvironmentId *int64 `json:"environmentId"` + // The application ID + Id *int64 `json:"id"` +} + +// GetCommand returns AppEnvironmentTriggerWPCLICommandInput.Command, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetCommand() *string { return v.Command } + +// GetEnvironmentId returns AppEnvironmentTriggerWPCLICommandInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentTriggerWPCLICommandInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetId() *int64 { return v.Id } + +// The strategies available for running WP-CLI commands. +type AppEnvironmentWPCliStrategy string + +const ( + // Run WP-CLI over SSH. + AppEnvironmentWPCliStrategySsh AppEnvironmentWPCliStrategy = "ssh" + // Run WP-CLI over a websocket connection. + AppEnvironmentWPCliStrategyWebsocket AppEnvironmentWPCliStrategy = "websocket" +) + +var AllAppEnvironmentWPCliStrategy = []AppEnvironmentWPCliStrategy{ + AppEnvironmentWPCliStrategySsh, + AppEnvironmentWPCliStrategyWebsocket, +} + +// AppGetByIDApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppGetByIDApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppGetByIDAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppGetByIDApp.Id, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetId() *int64 { return v.Id } + +// GetName returns AppGetByIDApp.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetName() *string { return v.Name } + +// GetRepo returns AppGetByIDApp.Repo, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppGetByIDApp.Environments, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetEnvironments() []*AppGetByIDAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppGetByIDAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppGetByIDAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The currently configured branch for the environment. + Branch *string `json:"branch"` + // The current deployed commit SHA. + CurrentCommit *string `json:"currentCommit"` + // The primary domain for the environment. + PrimaryDomain *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // The deployment strategy configured for the environment. + DeploymentStrategy *string `json:"deploymentStrategy"` +} + +// GetId returns AppGetByIDAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppGetByIDAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppGetByIDAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppGetByIDAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetBranch returns AppGetByIDAppEnvironmentsAppEnvironment.Branch, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetBranch() *string { return v.Branch } + +// GetCurrentCommit returns AppGetByIDAppEnvironmentsAppEnvironment.CurrentCommit, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetCurrentCommit() *string { return v.CurrentCommit } + +// GetPrimaryDomain returns AppGetByIDAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetPrimaryDomain() *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetLaunched returns AppGetByIDAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetDeploymentStrategy returns AppGetByIDAppEnvironmentsAppEnvironment.DeploymentStrategy, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetDeploymentStrategy() *string { + return v.DeploymentStrategy +} + +// AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// AppGetByIDResponse is returned by AppGetByID on success. +type AppGetByIDResponse struct { + // Retrieve a single application. + App *AppGetByIDApp `json:"app"` +} + +// GetApp returns AppGetByIDResponse.App, and is useful for accessing the field via an interface. +func (v *AppGetByIDResponse) GetApp() *AppGetByIDApp { return v.App } + +// AppGetByNameAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type AppGetByNameAppsAppList struct { + // A legacy alias for `nodes`. + Edges []*AppGetByNameAppsAppListEdgesApp `json:"edges"` +} + +// GetEdges returns AppGetByNameAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppList) GetEdges() []*AppGetByNameAppsAppListEdgesApp { return v.Edges } + +// AppGetByNameAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppGetByNameAppsAppListEdgesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppGetByNameAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetId() *int64 { return v.Id } + +// GetName returns AppGetByNameAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetName() *string { return v.Name } + +// GetRepo returns AppGetByNameAppsAppListEdgesApp.Repo, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppGetByNameAppsAppListEdgesApp.Environments, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetEnvironments() []*AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The currently configured branch for the environment. + Branch *string `json:"branch"` + // The current deployed commit SHA. + CurrentCommit *string `json:"currentCommit"` + // The primary domain for the environment. + PrimaryDomain *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // The deployment strategy configured for the environment. + DeploymentStrategy *string `json:"deploymentStrategy"` +} + +// GetId returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetBranch returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Branch, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetBranch() *string { + return v.Branch +} + +// GetCurrentCommit returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.CurrentCommit, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetCurrentCommit() *string { + return v.CurrentCommit +} + +// GetPrimaryDomain returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetPrimaryDomain() *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetLaunched returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetLaunched() *bool { + return v.Launched +} + +// GetDeploymentStrategy returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.DeploymentStrategy, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetDeploymentStrategy() *string { + return v.DeploymentStrategy +} + +// AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { + return v.Name +} + +// AppGetByNameResponse is returned by AppGetByName on success. +type AppGetByNameResponse struct { + // Retrieve a paginated list of applications. + Apps *AppGetByNameAppsAppList `json:"apps"` +} + +// GetApps returns AppGetByNameResponse.Apps, and is useful for accessing the field via an interface. +func (v *AppGetByNameResponse) GetApps() *AppGetByNameAppsAppList { return v.Apps } + +// AppListAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type AppListAppsAppList struct { + // The total number of matching applications. + Total *int64 `json:"total"` + // The cursor for the next page of applications. + NextCursor *string `json:"nextCursor"` + // A legacy alias for `nodes`. + Edges []*AppListAppsAppListEdgesApp `json:"edges"` +} + +// GetTotal returns AppListAppsAppList.Total, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetTotal() *int64 { return v.Total } + +// GetNextCursor returns AppListAppsAppList.NextCursor, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetNextCursor() *string { return v.NextCursor } + +// GetEdges returns AppListAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetEdges() []*AppListAppsAppListEdgesApp { return v.Edges } + +// AppListAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppListAppsAppListEdgesApp struct { + AppBasic `json:"-"` +} + +// GetId returns AppListAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetId() *int64 { return v.AppBasic.Id } + +// GetName returns AppListAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetName() *string { return v.AppBasic.Name } + +// GetRepo returns AppListAppsAppListEdgesApp.Repo, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetRepo() *string { return v.AppBasic.Repo } + +func (v *AppListAppsAppListEdgesApp) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppListAppsAppListEdgesApp + graphql.NoUnmarshalJSON + } + firstPass.AppListAppsAppListEdgesApp = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AppBasic) + if err != nil { + return err + } + return nil +} + +type __premarshalAppListAppsAppListEdgesApp struct { + Id *int64 `json:"id"` + + Name *string `json:"name"` + + Repo *string `json:"repo"` +} + +func (v *AppListAppsAppListEdgesApp) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppListAppsAppListEdgesApp) __premarshalJSON() (*__premarshalAppListAppsAppListEdgesApp, error) { + var retval __premarshalAppListAppsAppListEdgesApp + + retval.Id = v.AppBasic.Id + retval.Name = v.AppBasic.Name + retval.Repo = v.AppBasic.Repo + return &retval, nil +} + +// AppListResponse is returned by AppList on success. +type AppListResponse struct { + // Retrieve a paginated list of applications. + Apps *AppListAppsAppList `json:"apps"` +} + +// GetApps returns AppListResponse.Apps, and is useful for accessing the field via an interface. +func (v *AppListResponse) GetApps() *AppListAppsAppList { return v.Apps } + +// AppMappedDomainsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppMappedDomainsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppMappedDomainsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppMappedDomainsApp.Id, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetId() *int64 { return v.Id } + +// GetName returns AppMappedDomainsApp.Name, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetName() *string { return v.Name } + +// GetEnvironments returns AppMappedDomainsApp.Environments, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetEnvironments() []*AppMappedDomainsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppMappedDomainsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppMappedDomainsAppEnvironmentsAppEnvironment struct { + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // The domains mapped to the environment. + Domains *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList `json:"domains"` +} + +// GetUniqueLabel returns AppMappedDomainsAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { + return v.UniqueLabel +} + +// GetIsMultisite returns AppMappedDomainsAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetDomains returns AppMappedDomainsAppEnvironmentsAppEnvironment.Domains, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetDomains() *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList { + return v.Domains +} + +// AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList includes the requested fields of the GraphQL type DomainList. +// The GraphQL type's documentation follows. +// +// A paginated list of domains. +type AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList struct { + // The domains returned in the current page. + Nodes []*AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain `json:"nodes"` +} + +// GetNodes returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList.Nodes, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList) GetNodes() []*AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain { + return v.Nodes +} + +// AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` + // Is this the primary domain for the environment? + IsPrimary *bool `json:"isPrimary"` +} + +// GetName returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain.Name, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain) GetName() string { + return v.Name +} + +// GetIsPrimary returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain.IsPrimary, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain) GetIsPrimary() *bool { + return v.IsPrimary +} + +// AppMappedDomainsResponse is returned by AppMappedDomains on success. +type AppMappedDomainsResponse struct { + // Retrieve a single application. + App *AppMappedDomainsApp `json:"app"` +} + +// GetApp returns AppMappedDomainsResponse.App, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsResponse) GetApp() *AppMappedDomainsApp { return v.App } + +// AppMultiSiteCheckApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppMultiSiteCheckApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppMultiSiteCheckAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppMultiSiteCheckApp.Id, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetId() *int64 { return v.Id } + +// GetName returns AppMultiSiteCheckApp.Name, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetName() *string { return v.Name } + +// GetRepo returns AppMultiSiteCheckApp.Repo, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppMultiSiteCheckApp.Environments, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetEnvironments() []*AppMultiSiteCheckAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppMultiSiteCheckAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppMultiSiteCheckAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // Whether the multisite install uses subdirectories. + IsSubdirectoryMultisite *bool `json:"isSubdirectoryMultisite"` +} + +// GetId returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetIsMultisite returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetIsSubdirectoryMultisite returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.IsSubdirectoryMultisite, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetIsSubdirectoryMultisite() *bool { + return v.IsSubdirectoryMultisite +} + +// AppMultiSiteCheckResponse is returned by AppMultiSiteCheck on success. +type AppMultiSiteCheckResponse struct { + // Retrieve a single application. + App *AppMultiSiteCheckApp `json:"app"` +} + +// GetApp returns AppMultiSiteCheckResponse.App, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckResponse) GetApp() *AppMultiSiteCheckApp { return v.App } + +// BackupDBCopyResponse is returned by BackupDBCopy on success. +type BackupDBCopyResponse struct { + // Start copying a database backup. + StartDBBackupCopy *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload `json:"startDBBackupCopy"` +} + +// GetStartDBBackupCopy returns BackupDBCopyResponse.StartDBBackupCopy, and is useful for accessing the field via an interface. +func (v *BackupDBCopyResponse) GetStartDBBackupCopy() *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload { + return v.StartDBBackupCopy +} + +// BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload includes the requested fields of the GraphQL type AppEnvironmentStartDBBackupCopyPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a database backup copy. +type BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload struct { + // A human-readable result message. + Message *string `json:"message"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetMessage returns BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload.Message, and is useful for accessing the field via an interface. +func (v *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload) GetMessage() *string { + return v.Message +} + +// GetSuccess returns BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload.Success, and is useful for accessing the field via an interface. +func (v *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload) GetSuccess() *bool { + return v.Success +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload includes the requested fields of the GraphQL type EnvironmentVariablesPayload. +// The GraphQL type's documentation follows. +// +// The updated environment variable list after a mutation. +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload struct { + // The environment variables currently configured on the environment. + EnvironmentVariables *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetEnvironmentVariables returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload) GetEnvironmentVariables() *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// DeleteEnvironmentVariableResponse is returned by DeleteEnvironmentVariable on success. +type DeleteEnvironmentVariableResponse struct { + // Delete an environment variable from an application environment. + DeleteEnvironmentVariable *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload `json:"deleteEnvironmentVariable"` +} + +// GetDeleteEnvironmentVariable returns DeleteEnvironmentVariableResponse.DeleteEnvironmentVariable, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableResponse) GetDeleteEnvironmentVariable() *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload { + return v.DeleteEnvironmentVariable +} + +// DevEnvAppInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type DevEnvAppInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*DevEnvAppInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns DevEnvAppInfoApp.Id, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetId() *int64 { return v.Id } + +// GetName returns DevEnvAppInfoApp.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetName() *string { return v.Name } + +// GetEnvironments returns DevEnvAppInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetEnvironments() []*DevEnvAppInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type DevEnvAppInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // The primary domain for the environment. + PrimaryDomain *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // The environment variables configured for the environment. + EnvironmentVariables *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` + // The software settings for the environment. + SoftwareSettings *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings `json:"softwareSettings"` +} + +// GetId returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns DevEnvAppInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetIsMultisite returns DevEnvAppInfoAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetPrimaryDomain returns DevEnvAppInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetEnvironmentVariables returns DevEnvAppInfoAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetSoftwareSettings returns DevEnvAppInfoAppEnvironmentsAppEnvironment.SoftwareSettings, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetSoftwareSettings() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings { + return v.SoftwareSettings +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The environment variables for this environment + Nodes []*DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetNodes returns DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings struct { + // The PHP software settings. + Php *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The WordPress software settings. + Wordpress *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` +} + +// GetPhp returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings) GetPhp() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetWordpress returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings) GetWordpress() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + // The currently selected version. + Current *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` +} + +// GetCurrent returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` +} + +// GetVersion returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + // The currently selected version. + Current *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` +} + +// GetCurrent returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` +} + +// GetVersion returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// DevEnvAppInfoResponse is returned by DevEnvAppInfo on success. +type DevEnvAppInfoResponse struct { + // Retrieve a single application. + App *DevEnvAppInfoApp `json:"app"` +} + +// GetApp returns DevEnvAppInfoResponse.App, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoResponse) GetApp() *DevEnvAppInfoApp { return v.App } + +// DevEnvSyncSitesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type DevEnvSyncSitesApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*DevEnvSyncSitesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns DevEnvSyncSitesApp.Environments, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesApp) GetEnvironments() []*DevEnvSyncSitesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type DevEnvSyncSitesAppEnvironmentsAppEnvironment struct { + // Get WordPress Site Details from SDS + WpSitesSDS *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +} + +// GetWpSitesSDS returns DevEnvSyncSitesAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironment) GetWpSitesSDS() *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { + return v.WpSitesSDS +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// The GraphQL type's documentation follows. +// +// A paginated list of WordPress sites. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { + // The total number of matching WordPress sites. + Total *int64 `json:"total"` + // The cursor for the next page of WordPress sites. + NextCursor *string `json:"nextCursor"` + // The WordPress sites returned in the current page. + Nodes []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +} + +// GetTotal returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Total, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetTotal() *int64 { + return v.Total +} + +// GetNextCursor returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.NextCursor, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNextCursor() *string { + return v.NextCursor +} + +// GetNodes returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { + return v.Nodes +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. +// The GraphQL type's documentation follows. +// +// A WordPress site or subsite within an environment. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { + // WordPress Site/Blog ID + BlogId *int64 `json:"blogId"` + // WordPress Home URL option + HomeUrl *string `json:"homeUrl"` + // WordPress Site URL option + SiteUrl *string `json:"siteUrl"` +} + +// GetBlogId returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.BlogId, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetBlogId() *int64 { + return v.BlogId +} + +// GetHomeUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { + return v.HomeUrl +} + +// GetSiteUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.SiteUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetSiteUrl() *string { + return v.SiteUrl +} + +// DevEnvSyncSitesResponse is returned by DevEnvSyncSites on success. +type DevEnvSyncSitesResponse struct { + // Retrieve a single application. + App *DevEnvSyncSitesApp `json:"app"` +} + +// GetApp returns DevEnvSyncSitesResponse.App, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesResponse) GetApp() *DevEnvSyncSitesApp { return v.App } + +// EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload includes the requested fields of the GraphQL type EnablePhpMyAdminPayload. +// The GraphQL type's documentation follows. +// +// The result of enabling phpMyAdmin. +type EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload struct { + // Whether phpMyAdmin was enabled successfully. + Success *bool `json:"success"` +} + +// GetSuccess returns EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload.Success, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload) GetSuccess() *bool { + return v.Success +} + +// Input for enabling phpMyAdmin. +type EnablePhpMyAdminInput struct { + // The environment ID. + EnvironmentId int64 `json:"environmentId"` +} + +// GetEnvironmentId returns EnablePhpMyAdminInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// EnablePhpMyAdminResponse is returned by EnablePhpMyAdmin on success. +type EnablePhpMyAdminResponse struct { + // Enable phpMyAdmin for an environment. + EnablePHPMyAdmin *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload `json:"enablePHPMyAdmin"` +} + +// GetEnablePHPMyAdmin returns EnablePhpMyAdminResponse.EnablePHPMyAdmin, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminResponse) GetEnablePHPMyAdmin() *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload { + return v.EnablePHPMyAdmin +} + +// Input for creating, updating, or deleting an environment variable. +type EnvironmentVariableInput struct { + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // The unique ID of the environment + EnvironmentId int64 `json:"environmentId"` + // Environment variable name (must consist of uppercase letters, numbers, and underscore + Name string `json:"name"` + // Whether to reload the site manifest after the operation + ReloadManifest *bool `json:"reloadManifest"` + // Environment variable value + Value string `json:"value"` +} + +// GetApplicationId returns EnvironmentVariableInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetEnvironmentId returns EnvironmentVariableInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetName returns EnvironmentVariableInput.Name, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetName() string { return v.Name } + +// GetReloadManifest returns EnvironmentVariableInput.ReloadManifest, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetReloadManifest() *bool { return v.ReloadManifest } + +// GetValue returns EnvironmentVariableInput.Value, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetValue() string { return v.Value } + +// GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload includes the requested fields of the GraphQL type AppEnvironmentGenerateDBBackupCopyUrlPayload. +// The GraphQL type's documentation follows. +// +// The result of generating a database backup copy download URL. +type GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload struct { + // The generated download URL. + Url *string `json:"url"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetUrl returns GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload.Url, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload) GetUrl() *string { + return v.Url +} + +// GetSuccess returns GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload.Success, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload) GetSuccess() *bool { + return v.Success +} + +// GenerateDBBackupCopyUrlResponse is returned by GenerateDBBackupCopyUrl on success. +type GenerateDBBackupCopyUrlResponse struct { + // Generate a presigned download URL for a copied database backup. + GenerateDBBackupCopyUrl *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload `json:"generateDBBackupCopyUrl"` +} + +// GetGenerateDBBackupCopyUrl returns GenerateDBBackupCopyUrlResponse.GenerateDBBackupCopyUrl, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlResponse) GetGenerateDBBackupCopyUrl() *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload { + return v.GenerateDBBackupCopyUrl +} + +// GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload includes the requested fields of the GraphQL type AppEnvironmentLiveBackupCopyDownloadURLPayload. +// The GraphQL type's documentation follows. +// +// The result of generating a live backup copy download URL. +type GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // The generated download URL. + Url *string `json:"url"` + // Whether the live backup copy is still processing. + Processing bool `json:"processing"` + // The size of the downloadable copy in bytes. + Size *int64 `json:"size"` +} + +// GetSuccess returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Success, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetSuccess() bool { + return v.Success +} + +// GetUrl returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Url, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetUrl() *string { + return v.Url +} + +// GetProcessing returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Processing, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetProcessing() bool { + return v.Processing +} + +// GetSize returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Size, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetSize() *int64 { + return v.Size +} + +// GenerateLiveBackupCopyDownloadURLResponse is returned by GenerateLiveBackupCopyDownloadURL on success. +type GenerateLiveBackupCopyDownloadURLResponse struct { + // Generate a live backup copy download URL. + GenerateLiveBackupCopyDownloadURL *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload `json:"generateLiveBackupCopyDownloadURL"` +} + +// GetGenerateLiveBackupCopyDownloadURL returns GenerateLiveBackupCopyDownloadURLResponse.GenerateLiveBackupCopyDownloadURL, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLResponse) GetGenerateLiveBackupCopyDownloadURL() *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload { + return v.GenerateLiveBackupCopyDownloadURL +} + +// GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload includes the requested fields of the GraphQL type GeneratePhpMyAdminAccessPayload. +// The GraphQL type's documentation follows. +// +// The result of generating phpMyAdmin access. +type GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload struct { + // The generated phpMyAdmin URL. + Url *string `json:"url"` +} + +// GetUrl returns GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload.Url, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload) GetUrl() *string { + return v.Url +} + +// Input for generating phpMyAdmin access. +type GeneratePhpMyAdminAccessInput struct { + // The environment ID. + EnvironmentId int64 `json:"environmentId"` +} + +// GetEnvironmentId returns GeneratePhpMyAdminAccessInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GeneratePhpMyAdminAccessResponse is returned by GeneratePhpMyAdminAccess on success. +type GeneratePhpMyAdminAccessResponse struct { + // Generate temporary phpMyAdmin access for an environment. + GeneratePHPMyAdminAccess *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload `json:"generatePHPMyAdminAccess"` +} + +// GetGeneratePHPMyAdminAccess returns GeneratePhpMyAdminAccessResponse.GeneratePHPMyAdminAccess, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessResponse) GetGeneratePHPMyAdminAccess() *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload { + return v.GeneratePHPMyAdminAccess +} + +// GetAppLogsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetAppLogsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetAppLogsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetAppLogsApp.Id, and is useful for accessing the field via an interface. +func (v *GetAppLogsApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetAppLogsApp.Environments, and is useful for accessing the field via an interface. +func (v *GetAppLogsApp) GetEnvironments() []*GetAppLogsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetAppLogsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetAppLogsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Application and platform logs for the environment. Use `type: app` or `type: batch`. Returns `pollingDelaySeconds` to guide incremental polling. + Logs *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList `json:"logs"` +} + +// GetId returns GetAppLogsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetLogs returns GetAppLogsAppEnvironmentsAppEnvironment.Logs, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironment) GetLogs() *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList { + return v.Logs +} + +// GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList includes the requested fields of the GraphQL type AppEnvironmentLogsList. +// The GraphQL type's documentation follows. +// +// A paginated list of environment log entries. +type GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList struct { + // The log entries returned in the current page. + Nodes []*GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog `json:"nodes"` + // The cursor for the next page of log entries. + NextCursor *string `json:"nextCursor"` + // The suggested polling delay before fetching logs again. + PollingDelaySeconds int64 `json:"pollingDelaySeconds"` +} + +// GetNodes returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.Nodes, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetNodes() []*GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog { + return v.Nodes +} + +// GetNextCursor returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.NextCursor, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetNextCursor() *string { + return v.NextCursor +} + +// GetPollingDelaySeconds returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.PollingDelaySeconds, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetPollingDelaySeconds() int64 { + return v.PollingDelaySeconds +} + +// GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog includes the requested fields of the GraphQL type AppEnvironmentLog. +// The GraphQL type's documentation follows. +// +// A single environment log entry. +type GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog struct { + // When the log entry was recorded. + Timestamp *string `json:"timestamp"` + // The log message. + Message *string `json:"message"` +} + +// GetTimestamp returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog.Timestamp, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog) GetTimestamp() *string { + return v.Timestamp +} + +// GetMessage returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog.Message, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog) GetMessage() *string { + return v.Message +} + +// GetAppLogsResponse is returned by GetAppLogs on success. +type GetAppLogsResponse struct { + // Retrieve a single application. + App *GetAppLogsApp `json:"app"` +} + +// GetApp returns GetAppLogsResponse.App, and is useful for accessing the field via an interface. +func (v *GetAppLogsResponse) GetApp() *GetAppLogsApp { return v.App } + +// GetAppSlowlogsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetAppSlowlogsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetAppSlowlogsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetAppSlowlogsApp.Id, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetAppSlowlogsApp.Environments, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsApp) GetEnvironments() []*GetAppSlowlogsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetAppSlowlogsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Database slow query logs for the environment. + Slowlogs *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList `json:"slowlogs"` +} + +// GetId returns GetAppSlowlogsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSlowlogs returns GetAppSlowlogsAppEnvironmentsAppEnvironment.Slowlogs, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironment) GetSlowlogs() *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList { + return v.Slowlogs +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList includes the requested fields of the GraphQL type AppEnvironmentSlowlogsList. +// The GraphQL type's documentation follows. +// +// A paginated list of slow log entries. +type GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList struct { + // The slow log entries returned in the current page. + Nodes []*GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog `json:"nodes"` + // The cursor for the next page of slow log entries. + NextCursor *string `json:"nextCursor"` + // The suggested polling delay before fetching slow logs again. + PollingDelaySeconds int64 `json:"pollingDelaySeconds"` +} + +// GetNodes returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.Nodes, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetNodes() []*GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog { + return v.Nodes +} + +// GetNextCursor returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.NextCursor, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetNextCursor() *string { + return v.NextCursor +} + +// GetPollingDelaySeconds returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.PollingDelaySeconds, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetPollingDelaySeconds() int64 { + return v.PollingDelaySeconds +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog includes the requested fields of the GraphQL type AppEnvironmentSlowlog. +// The GraphQL type's documentation follows. +// +// A single slow query log entry. +type GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog struct { + // When the slow query was recorded. + Timestamp *string `json:"timestamp"` + // The number of rows returned by the query. + RowsSent *string `json:"rowsSent"` + // The number of rows examined by the query. + RowsExamined *string `json:"rowsExamined"` + // How long the query took to execute. + QueryTime *string `json:"queryTime"` + // The request URI associated with the slow query. + RequestUri *string `json:"requestUri"` + // The SQL query text. + Query *string `json:"query"` +} + +// GetTimestamp returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.Timestamp, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetTimestamp() *string { + return v.Timestamp +} + +// GetRowsSent returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RowsSent, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRowsSent() *string { + return v.RowsSent +} + +// GetRowsExamined returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RowsExamined, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRowsExamined() *string { + return v.RowsExamined +} + +// GetQueryTime returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.QueryTime, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetQueryTime() *string { + return v.QueryTime +} + +// GetRequestUri returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RequestUri, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRequestUri() *string { + return v.RequestUri +} + +// GetQuery returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.Query, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetQuery() *string { + return v.Query +} + +// GetAppSlowlogsResponse is returned by GetAppSlowlogs on success. +type GetAppSlowlogsResponse struct { + // Retrieve a single application. + App *GetAppSlowlogsApp `json:"app"` +} + +// GetApp returns GetAppSlowlogsResponse.App, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsResponse) GetApp() *GetAppSlowlogsApp { return v.App } + +// GetEnvironmentVariablesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetEnvironmentVariablesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetEnvironmentVariablesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetEnvironmentVariablesApp.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetEnvironmentVariablesApp.Environments, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesApp) GetEnvironments() []*GetEnvironmentVariablesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetEnvironmentVariablesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The environment variables configured for the environment. + EnvironmentVariables *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetId returns GetEnvironmentVariablesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEnvironmentVariables returns GetEnvironmentVariablesAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// GetEnvironmentVariablesResponse is returned by GetEnvironmentVariables on success. +type GetEnvironmentVariablesResponse struct { + // Retrieve a single application. + App *GetEnvironmentVariablesApp `json:"app"` +} + +// GetApp returns GetEnvironmentVariablesResponse.App, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesResponse) GetApp() *GetEnvironmentVariablesApp { return v.App } + +// GetEnvironmentVariablesWithValuesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetEnvironmentVariablesWithValuesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetEnvironmentVariablesWithValuesApp.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetEnvironmentVariablesWithValuesApp.Environments, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesApp) GetEnvironments() []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The environment variables configured for the environment. + EnvironmentVariables *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetId returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEnvironmentVariables returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` + // Environment variable value + Value *string `json:"value"` +} + +// GetName returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// GetValue returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Value, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetValue() *string { + return v.Value +} + +// GetEnvironmentVariablesWithValuesResponse is returned by GetEnvironmentVariablesWithValues on success. +type GetEnvironmentVariablesWithValuesResponse struct { + // Retrieve a single application. + App *GetEnvironmentVariablesWithValuesApp `json:"app"` +} + +// GetApp returns GetEnvironmentVariablesWithValuesResponse.App, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesResponse) GetApp() *GetEnvironmentVariablesWithValuesApp { + return v.App +} + +// ImportSQLEnvInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ImportSQLEnvInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ImportSQLEnvInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ImportSQLEnvInfoApp.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetId() *int64 { return v.Id } + +// GetName returns ImportSQLEnvInfoApp.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetName() *string { return v.Name } + +// GetTypeId returns ImportSQLEnvInfoApp.TypeId, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ImportSQLEnvInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetEnvironments() []*ImportSQLEnvInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // Whether the environment runs on Kubernetes. + IsK8sResident *bool `json:"isK8sResident"` + // The primary domain for the environment. + PrimaryDomain *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // The current import status for the environment. + ImportStatus *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` + // Get WordPress Site Details from SDS + WpSitesSDS *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +} + +// GetId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetLaunched returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetIsK8sResident returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.IsK8sResident, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetIsK8sResident() *bool { + return v.IsK8sResident +} + +// GetPrimaryDomain returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetImportStatus returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.ImportStatus, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetImportStatus() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus { + return v.ImportStatus +} + +// GetWpSitesSDS returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetWpSitesSDS() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { + return v.WpSitesSDS +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus includes the requested fields of the GraphQL type AppEnvironmentImportStatus. +// The GraphQL type's documentation follows. +// +// The current status of an environment import. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus struct { + // Whether any database operation is currently in progress. + DbOperationInProgress *bool `json:"dbOperationInProgress"` + // Whether an import is currently in progress. + ImportInProgress *bool `json:"importInProgress"` +} + +// GetDbOperationInProgress returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus.DbOperationInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus) GetDbOperationInProgress() *bool { + return v.DbOperationInProgress +} + +// GetImportInProgress returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus.ImportInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus) GetImportInProgress() *bool { + return v.ImportInProgress +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// The GraphQL type's documentation follows. +// +// A paginated list of WordPress sites. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { + // The WordPress sites returned in the current page. + Nodes []*ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +} + +// GetNodes returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { + return v.Nodes +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. +// The GraphQL type's documentation follows. +// +// A WordPress site or subsite within an environment. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { + // WordPress Home URL option + HomeUrl *string `json:"homeUrl"` + // [DEPRECATING SOON] Alias for blogId + Id *int64 `json:"id"` +} + +// GetHomeUrl returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { + return v.HomeUrl +} + +// GetId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetId() *int64 { + return v.Id +} + +// ImportSQLEnvInfoResponse is returned by ImportSQLEnvInfo on success. +type ImportSQLEnvInfoResponse struct { + // Retrieve a single application. + App *ImportSQLEnvInfoApp `json:"app"` +} + +// GetApp returns ImportSQLEnvInfoResponse.App, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoResponse) GetApp() *ImportSQLEnvInfoApp { return v.App } + +// ImportSQLProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ImportSQLProgressApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ImportSQLProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns ImportSQLProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressApp) GetEnvironments() []*ImportSQLProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ImportSQLProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ImportSQLProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Whether the environment runs on Kubernetes. + IsK8sResident *bool `json:"isK8sResident"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // Jobs running on or related to the environment. + Jobs []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` + // The current import status for the environment. + ImportStatus *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetIsK8sResident returns ImportSQLProgressAppEnvironmentsAppEnvironment.IsK8sResident, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetIsK8sResident() *bool { + return v.IsK8sResident +} + +// GetLaunched returns ImportSQLProgressAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetJobs returns ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetJobs() []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +// GetImportStatus returns ImportSQLProgressAppEnvironmentsAppEnvironment.ImportStatus, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetImportStatus() *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus { + return v.ImportStatus +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *ImportSQLProgressAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.ImportSQLProgressAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalImportSQLProgressAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + IsK8sResident *bool `json:"isK8sResident"` + + Launched *bool `json:"launched"` + + Jobs []json.RawMessage `json:"jobs"` + + ImportStatus *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalImportSQLProgressAppEnvironmentsAppEnvironment, error) { + var retval __premarshalImportSQLProgressAppEnvironmentsAppEnvironment + + retval.Id = v.Id + retval.IsK8sResident = v.IsK8sResident + retval.Launched = v.Launched + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + retval.ImportStatus = v.ImportStatus + return &retval, nil +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus includes the requested fields of the GraphQL type AppEnvironmentImportStatus. +// The GraphQL type's documentation follows. +// +// The current status of an environment import. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus struct { + // Whether any database operation is currently in progress. + DbOperationInProgress *bool `json:"dbOperationInProgress"` + // Whether an import is currently in progress. + ImportInProgress *bool `json:"importInProgress"` + // Detailed progress information for the import. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress `json:"progress"` +} + +// GetDbOperationInProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.DbOperationInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetDbOperationInProgress() *bool { + return v.DbOperationInProgress +} + +// GetImportInProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.ImportInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetImportInProgress() *bool { + return v.ImportInProgress +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress { + return v.Progress +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress includes the requested fields of the GraphQL type AppEnvironmentStatusProgress. +// The GraphQL type's documentation follows. +// +// Progress details for an environment operation. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress struct { + // When the operation started, as a Unix timestamp. + Started_at *int64 `json:"started_at"` + // The steps completed by the operation. + Steps []*ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep `json:"steps"` + // When the operation finished, as a Unix timestamp. + Finished_at *int64 `json:"finished_at"` +} + +// GetStarted_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Started_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetStarted_at() *int64 { + return v.Started_at +} + +// GetSteps returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Steps, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetSteps() []*ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep { + return v.Steps +} + +// GetFinished_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Finished_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetFinished_at() *int64 { + return v.Finished_at +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep includes the requested fields of the GraphQL type AppEnvironmentStatusProgressStep. +// The GraphQL type's documentation follows. +// +// A single step in an environment progress flow. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep struct { + // The display name of the step. + Name *string `json:"name"` + // When the step started, as a Unix timestamp. + Started_at *int64 `json:"started_at"` + // When the step finished, as a Unix timestamp. + Finished_at *int64 `json:"finished_at"` + // The result of the step. + Result *string `json:"result"` + // The output lines produced by the step. + Output []*string `json:"output"` +} + +// GetName returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetName() *string { + return v.Name +} + +// GetStarted_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Started_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetStarted_at() *int64 { + return v.Started_at +} + +// GetFinished_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Finished_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetFinished_at() *int64 { + return v.Finished_at +} + +// GetResult returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Result, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetResult() *string { + return v.Result +} + +// GetOutput returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Output, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetOutput() []*string { + return v.Output +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // The current progress of the job. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface(v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The unique identifier for the step. + Id *string `json:"id"` + // The display name of the step. + Name *string `json:"name"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetId() *string { + return v.Id +} + +// GetName returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStatus returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // The current progress of the job. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// ImportSQLProgressResponse is returned by ImportSQLProgress on success. +type ImportSQLProgressResponse struct { + // Retrieve a single application. + App *ImportSQLProgressApp `json:"app"` +} + +// GetApp returns ImportSQLProgressResponse.App, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressResponse) GetApp() *ImportSQLProgressApp { return v.App } + +// Input for starting a live backup copy. +type LiveBackupCopyConfigInput struct { + // The live backup copy configuration payload. + Config *json.RawMessage `json:"config"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetConfig returns LiveBackupCopyConfigInput.Config, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetConfig() *json.RawMessage { return v.Config } + +// GetEnvironmentId returns LiveBackupCopyConfigInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns LiveBackupCopyConfigInput.Id, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetId() int64 { return v.Id } + +// MeMe includes the requested fields of the GraphQL type Me. +// The GraphQL type's documentation follows. +// +// The currently authenticated user. +type MeMe struct { + // The unique identifier for the current user. + Id *int64 `json:"id"` + // The display name for the current user. + DisplayName *string `json:"displayName"` + // Whether the current user currently has VIP access. + IsVIP *bool `json:"isVIP"` + // The organization roles assigned to the current user. + OrganizationRoles *MeMeOrganizationRolesUserOrganizationRoleList `json:"organizationRoles"` +} + +// GetId returns MeMe.Id, and is useful for accessing the field via an interface. +func (v *MeMe) GetId() *int64 { return v.Id } + +// GetDisplayName returns MeMe.DisplayName, and is useful for accessing the field via an interface. +func (v *MeMe) GetDisplayName() *string { return v.DisplayName } + +// GetIsVIP returns MeMe.IsVIP, and is useful for accessing the field via an interface. +func (v *MeMe) GetIsVIP() *bool { return v.IsVIP } + +// GetOrganizationRoles returns MeMe.OrganizationRoles, and is useful for accessing the field via an interface. +func (v *MeMe) GetOrganizationRoles() *MeMeOrganizationRolesUserOrganizationRoleList { + return v.OrganizationRoles +} + +// MeMeOrganizationRolesUserOrganizationRoleList includes the requested fields of the GraphQL type UserOrganizationRoleList. +// The GraphQL type's documentation follows. +// +// A paginated list of user organization roles. +type MeMeOrganizationRolesUserOrganizationRoleList struct { + // The role assignments returned in the current page. + Nodes []*MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole `json:"nodes"` +} + +// GetNodes returns MeMeOrganizationRolesUserOrganizationRoleList.Nodes, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleList) GetNodes() []*MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole { + return v.Nodes +} + +// MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole includes the requested fields of the GraphQL type UserOrganizationRole. +// The GraphQL type's documentation follows. +// +// An organization role assigned to a user. +type MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole struct { + // The organization ID the role applies to. + OrganizationId *int64 `json:"organizationId"` + // The role ID assigned to the user. + RoleId *OrgRoleId `json:"roleId"` +} + +// GetOrganizationId returns MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole.OrganizationId, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole) GetOrganizationId() *int64 { + return v.OrganizationId +} + +// GetRoleId returns MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole.RoleId, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole) GetRoleId() *OrgRoleId { + return v.RoleId +} + +// MeResponse is returned by Me on success. +type MeResponse struct { + // Retrieve the currently authenticated user. + Me *MeMe `json:"me"` +} + +// GetMe returns MeResponse.Me, and is useful for accessing the field via an interface. +func (v *MeResponse) GetMe() *MeMe { return v.Me } + +// MediaImportConfigMediaImportConfig includes the requested fields of the GraphQL type MediaImportConfig. +// The GraphQL type's documentation follows. +// +// Media Import Configuration +type MediaImportConfigMediaImportConfig struct { + // Allowed File Name Length + FileNameCharCount *int64 `json:"fileNameCharCount"` + // Allowed File Size Limit + FileSizeLimitInBytes *int64 `json:"fileSizeLimitInBytes"` + // Allowed File Types + AllowedFileTypes *json.RawMessage `json:"allowedFileTypes"` +} + +// GetFileNameCharCount returns MediaImportConfigMediaImportConfig.FileNameCharCount, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetFileNameCharCount() *int64 { + return v.FileNameCharCount +} + +// GetFileSizeLimitInBytes returns MediaImportConfigMediaImportConfig.FileSizeLimitInBytes, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetFileSizeLimitInBytes() *int64 { + return v.FileSizeLimitInBytes +} + +// GetAllowedFileTypes returns MediaImportConfigMediaImportConfig.AllowedFileTypes, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetAllowedFileTypes() *json.RawMessage { + return v.AllowedFileTypes +} + +// MediaImportConfigResponse is returned by MediaImportConfig on success. +type MediaImportConfigResponse struct { + // Retrieve the current media import configuration. + MediaImportConfig *MediaImportConfigMediaImportConfig `json:"mediaImportConfig"` +} + +// GetMediaImportConfig returns MediaImportConfigResponse.MediaImportConfig, and is useful for accessing the field via an interface. +func (v *MediaImportConfigResponse) GetMediaImportConfig() *MediaImportConfigMediaImportConfig { + return v.MediaImportConfig +} + +// MediaImportProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type MediaImportProgressApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*MediaImportProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns MediaImportProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *MediaImportProgressApp) GetEnvironments() []*MediaImportProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// MediaImportProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type MediaImportProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The repository name for the environment's codebase. + Repo *string `json:"repo"` + // The current media import status for the environment. + MediaImportStatus *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus `json:"mediaImportStatus"` +} + +// GetId returns MediaImportProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetName returns MediaImportProgressAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns MediaImportProgressAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetRepo returns MediaImportProgressAppEnvironmentsAppEnvironment.Repo, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetRepo() *string { return v.Repo } + +// GetMediaImportStatus returns MediaImportProgressAppEnvironmentsAppEnvironment.MediaImportStatus, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetMediaImportStatus() *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus { + return v.MediaImportStatus +} + +// MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatus. +// The GraphQL type's documentation follows. +// +// Current status of a Media Import +type MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The actual status of the Media Import + Status *string `json:"status"` + // Total number of media files that are to be import + FilesTotal *int64 `json:"filesTotal"` + // Total number of media files that were imported + FilesProcessed *int64 `json:"filesProcessed"` + // Media Import failure details + FailureDetails *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails `json:"failureDetails"` +} + +// GetImportId returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.ImportId, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.SiteId, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatus returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.Status, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetStatus() *string { + return v.Status +} + +// GetFilesTotal returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FilesTotal, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFilesTotal() *int64 { + return v.FilesTotal +} + +// GetFilesProcessed returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FilesProcessed, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFilesProcessed() *int64 { + return v.FilesProcessed +} + +// GetFailureDetails returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FailureDetails, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFailureDetails() *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails { + return v.FailureDetails +} + +// MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatusFailureDetails. +// The GraphQL type's documentation follows. +// +// Media Import Failure details +type MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails struct { + // Status of the Media Import prior to failing + PreviousStatus *string `json:"previousStatus"` + // List of global errors per import + GlobalErrors []*string `json:"globalErrors"` + // URL to download the media import error log + FileErrorsUrl *string `json:"fileErrorsUrl"` +} + +// GetPreviousStatus returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.PreviousStatus, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetPreviousStatus() *string { + return v.PreviousStatus +} + +// GetGlobalErrors returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.GlobalErrors, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetGlobalErrors() []*string { + return v.GlobalErrors +} + +// GetFileErrorsUrl returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.FileErrorsUrl, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetFileErrorsUrl() *string { + return v.FileErrorsUrl +} + +// MediaImportProgressResponse is returned by MediaImportProgress on success. +type MediaImportProgressResponse struct { + // Retrieve a single application. + App *MediaImportProgressApp `json:"app"` +} + +// GetApp returns MediaImportProgressResponse.App, and is useful for accessing the field via an interface. +func (v *MediaImportProgressResponse) GetApp() *MediaImportProgressApp { return v.App } + +// The available organization role IDs. +type OrgRoleId string + +const ( + // Organization administrator. + OrgRoleIdAdmin OrgRoleId = "admin" + // Organization member. + OrgRoleIdMember OrgRoleId = "member" + // Organization viewer. + OrgRoleIdViewer OrgRoleId = "viewer" +) + +var AllOrgRoleId = []OrgRoleId{ + OrgRoleIdAdmin, + OrgRoleIdMember, + OrgRoleIdViewer, +} + +// PhpMyAdminStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type PhpMyAdminStatusApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*PhpMyAdminStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns PhpMyAdminStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusApp) GetEnvironments() []*PhpMyAdminStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// PhpMyAdminStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type PhpMyAdminStatusAppEnvironmentsAppEnvironment struct { + // The phpMyAdmin availability status for the environment. + PhpMyAdminStatus *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus `json:"phpMyAdminStatus"` +} + +// GetPhpMyAdminStatus returns PhpMyAdminStatusAppEnvironmentsAppEnvironment.PhpMyAdminStatus, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusAppEnvironmentsAppEnvironment) GetPhpMyAdminStatus() *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus { + return v.PhpMyAdminStatus +} + +// PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus includes the requested fields of the GraphQL type PHPMyAdminStatus. +// The GraphQL type's documentation follows. +// +// The phpMyAdmin status for an environment. +type PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus struct { + // The current phpMyAdmin status value. + Status *string `json:"status"` +} + +// GetStatus returns PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus.Status, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus) GetStatus() *string { + return v.Status +} + +// PhpMyAdminStatusResponse is returned by PhpMyAdminStatus on success. +type PhpMyAdminStatusResponse struct { + // Retrieve a single application. + App *PhpMyAdminStatusApp `json:"app"` +} + +// GetApp returns PhpMyAdminStatusResponse.App, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusResponse) GetApp() *PhpMyAdminStatusApp { return v.App } + +// Input for purging page cache entries. +type PurgePageCacheInput struct { + // The application ID whose cache should be purged. + AppId int64 `json:"appId"` + // The environment ID whose cache should be purged. + EnvironmentId int64 `json:"environmentId"` + // The URLs to purge from page cache. + Urls []string `json:"urls"` +} + +// GetAppId returns PurgePageCacheInput.AppId, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetAppId() int64 { return v.AppId } + +// GetEnvironmentId returns PurgePageCacheInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetUrls returns PurgePageCacheInput.Urls, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetUrls() []string { return v.Urls } + +// PurgePageCachePurgePageCachePurgePageCachePayload includes the requested fields of the GraphQL type PurgePageCachePayload. +// The GraphQL type's documentation follows. +// +// The result of a page cache purge request. +type PurgePageCachePurgePageCachePurgePageCachePayload struct { + // Whether the purge request succeeded. + Success bool `json:"success"` + // The URLs that were targeted for purge. + Urls []string `json:"urls"` +} + +// GetSuccess returns PurgePageCachePurgePageCachePurgePageCachePayload.Success, and is useful for accessing the field via an interface. +func (v *PurgePageCachePurgePageCachePurgePageCachePayload) GetSuccess() bool { return v.Success } + +// GetUrls returns PurgePageCachePurgePageCachePurgePageCachePayload.Urls, and is useful for accessing the field via an interface. +func (v *PurgePageCachePurgePageCachePurgePageCachePayload) GetUrls() []string { return v.Urls } + +// PurgePageCacheResponse is returned by PurgePageCache on success. +type PurgePageCacheResponse struct { + // Purge page cache object(s) + PurgePageCache *PurgePageCachePurgePageCachePurgePageCachePayload `json:"purgePageCache"` +} + +// GetPurgePageCache returns PurgePageCacheResponse.PurgePageCache, and is useful for accessing the field via an interface. +func (v *PurgePageCacheResponse) GetPurgePageCache() *PurgePageCachePurgePageCachePurgePageCachePayload { + return v.PurgePageCache +} + +// A request header to include in a cache debug request. +type RequestHeader struct { + // The header name. + Name string `json:"name"` + // The header value. + Value string `json:"value"` +} + +// GetName returns RequestHeader.Name, and is useful for accessing the field via an interface. +func (v *RequestHeader) GetName() string { return v.Name } + +// GetValue returns RequestHeader.Value, and is useful for accessing the field via an interface. +func (v *RequestHeader) GetValue() string { return v.Value } + +// ResolveAppByIDApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ResolveAppByIDApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The application platform type, such as WordPress or Node.js. + Type *string `json:"type"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ResolveAppByIDAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ResolveAppByIDApp.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetId() *int64 { return v.Id } + +// GetName returns ResolveAppByIDApp.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetName() *string { return v.Name } + +// GetType returns ResolveAppByIDApp.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetType() *string { return v.Type } + +// GetTypeId returns ResolveAppByIDApp.TypeId, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ResolveAppByIDApp.Environments, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetEnvironments() []*ResolveAppByIDAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ResolveAppByIDAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ResolveAppByIDAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // The default domain assigned to the environment. + DefaultDomain *string `json:"defaultDomain"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` +} + +// GetId returns ResolveAppByIDAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ResolveAppByIDAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns ResolveAppByIDAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns ResolveAppByIDAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetUniqueLabel returns ResolveAppByIDAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { return v.UniqueLabel } + +// GetDefaultDomain returns ResolveAppByIDAppEnvironmentsAppEnvironment.DefaultDomain, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetDefaultDomain() *string { + return v.DefaultDomain +} + +// GetIsMultisite returns ResolveAppByIDAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// ResolveAppByIDResponse is returned by ResolveAppByID on success. +type ResolveAppByIDResponse struct { + // Retrieve a single application. + App *ResolveAppByIDApp `json:"app"` +} + +// GetApp returns ResolveAppByIDResponse.App, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDResponse) GetApp() *ResolveAppByIDApp { return v.App } + +// ResolveAppByNameAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type ResolveAppByNameAppsAppList struct { + // A legacy alias for `nodes`. + Edges []*ResolveAppByNameAppsAppListEdgesApp `json:"edges"` +} + +// GetEdges returns ResolveAppByNameAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppList) GetEdges() []*ResolveAppByNameAppsAppListEdgesApp { + return v.Edges +} + +// ResolveAppByNameAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ResolveAppByNameAppsAppListEdgesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The application platform type, such as WordPress or Node.js. + Type *string `json:"type"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ResolveAppByNameAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetId() *int64 { return v.Id } + +// GetName returns ResolveAppByNameAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetName() *string { return v.Name } + +// GetType returns ResolveAppByNameAppsAppListEdgesApp.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetType() *string { return v.Type } + +// GetTypeId returns ResolveAppByNameAppsAppListEdgesApp.TypeId, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ResolveAppByNameAppsAppListEdgesApp.Environments, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetEnvironments() []*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // The default domain assigned to the environment. + DefaultDomain *string `json:"defaultDomain"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` +} + +// GetId returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetAppId() *int64 { + return v.AppId +} + +// GetName returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetName() *string { + return v.Name +} + +// GetType returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetType() *string { + return v.Type +} + +// GetUniqueLabel returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { + return v.UniqueLabel +} + +// GetDefaultDomain returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.DefaultDomain, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetDefaultDomain() *string { + return v.DefaultDomain +} + +// GetIsMultisite returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { + return v.IsMultisite +} + +// ResolveAppByNameResponse is returned by ResolveAppByName on success. +type ResolveAppByNameResponse struct { + // Retrieve a paginated list of applications. + Apps *ResolveAppByNameAppsAppList `json:"apps"` +} + +// GetApps returns ResolveAppByNameResponse.Apps, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameResponse) GetApps() *ResolveAppByNameAppsAppList { return v.Apps } + +// SoftwareNode includes the GraphQL fields of AppEnvironmentSoftwareSettingsSoftware requested by the fragment SoftwareNode. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareNode struct { + // The display name of the software. + Name string `json:"name"` + // The internal slug of the software. + Slug string `json:"slug"` + // Whether the software version is pinned. + Pinned bool `json:"pinned"` + // The currently selected version. + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + // The available version options. + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +// GetName returns SoftwareNode.Name, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetName() string { return v.Name } + +// GetSlug returns SoftwareNode.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetSlug() string { return v.Slug } + +// GetPinned returns SoftwareNode.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetPinned() bool { return v.Pinned } + +// GetCurrent returns SoftwareNode.Current, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// GetOptions returns SoftwareNode.Options, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.Options +} + +// SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` + // Whether this is the default version. + Default bool `json:"default"` + // Whether this version is deprecated. + Deprecated bool `json:"deprecated"` + // Whether this version is unstable. + Unstable bool `json:"unstable"` + // Whether this version is compatible with the environment. + Compatible bool `json:"compatible"` + // The latest available release for this software. + LatestRelease string `json:"latestRelease"` + // Whether this version is private. + Private bool `json:"private"` +} + +// GetVersion returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// GetDefault returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Default, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetDefault() bool { + return v.Default +} + +// GetDeprecated returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Deprecated, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetDeprecated() bool { + return v.Deprecated +} + +// GetUnstable returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Unstable, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetUnstable() bool { + return v.Unstable +} + +// GetCompatible returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Compatible, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetCompatible() bool { + return v.Compatible +} + +// GetLatestRelease returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.LatestRelease, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetLatestRelease() string { + return v.LatestRelease +} + +// GetPrivate returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Private, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetPrivate() bool { + return v.Private +} + +// SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` + // Whether this is the default version. + Default bool `json:"default"` + // Whether this version is deprecated. + Deprecated bool `json:"deprecated"` + // Whether this version is unstable. + Unstable bool `json:"unstable"` + // Whether this version is compatible with the environment. + Compatible bool `json:"compatible"` + // The latest available release for this software. + LatestRelease string `json:"latestRelease"` + // Whether this version is private. + Private bool `json:"private"` +} + +// GetVersion returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// GetDefault returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Default, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetDefault() bool { + return v.Default +} + +// GetDeprecated returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Deprecated, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetDeprecated() bool { + return v.Deprecated +} + +// GetUnstable returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Unstable, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetUnstable() bool { + return v.Unstable +} + +// GetCompatible returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Compatible, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetCompatible() bool { + return v.Compatible +} + +// GetLatestRelease returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.LatestRelease, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetLatestRelease() string { + return v.LatestRelease +} + +// GetPrivate returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Private, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetPrivate() bool { + return v.Private +} + +// SoftwareSettingsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SoftwareSettingsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SoftwareSettingsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SoftwareSettingsApp.Id, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetId() *int64 { return v.Id } + +// GetName returns SoftwareSettingsApp.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetName() *string { return v.Name } + +// GetTypeId returns SoftwareSettingsApp.TypeId, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns SoftwareSettingsApp.Environments, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetEnvironments() []*SoftwareSettingsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SoftwareSettingsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SoftwareSettingsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // The software settings for the environment. + SoftwareSettings *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings `json:"softwareSettings"` +} + +// GetId returns SoftwareSettingsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns SoftwareSettingsAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns SoftwareSettingsAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetSoftwareSettings returns SoftwareSettingsAppEnvironmentsAppEnvironment.SoftwareSettings, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetSoftwareSettings() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings { + return v.SoftwareSettings +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings struct { + // The WordPress software settings. + Wordpress *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` + // The PHP software settings. + Php *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The mu-plugins software settings. + Muplugins *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware `json:"muplugins"` + // The Node.js software settings. + Nodejs *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware `json:"nodejs"` +} + +// GetWordpress returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetWordpress() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// GetPhp returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetPhp() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetMuplugins returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Muplugins, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetMuplugins() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware { + return v.Muplugins +} + +// GetNodejs returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Nodejs, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetNodejs() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware { + return v.Nodejs +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsResponse is returned by SoftwareSettings on success. +type SoftwareSettingsResponse struct { + // Retrieve a single application. + App *SoftwareSettingsApp `json:"app"` +} + +// GetApp returns SoftwareSettingsResponse.App, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsResponse) GetApp() *SoftwareSettingsApp { return v.App } + +// SoftwareUpdateJobApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SoftwareUpdateJobApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SoftwareUpdateJobAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns SoftwareUpdateJobApp.Environments, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobApp) GetEnvironments() []*SoftwareUpdateJobAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SoftwareUpdateJobAppEnvironmentsAppEnvironment struct { + // Jobs running on or related to the environment. + Jobs []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetJobs returns SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) GetJobs() []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareUpdateJobAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.SoftwareUpdateJobAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment struct { + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment, error) { + var retval __premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment + + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // The current progress of the job. + Progress *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetType returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetProgress returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface(v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The step key. + Step *string `json:"step"` + // The display name of the step. + Name *string `json:"name"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetStep returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Step, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStep() *string { + return v.Step +} + +// GetName returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStatus returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // The current progress of the job. + Progress *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetType returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetProgress returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// SoftwareUpdateJobResponse is returned by SoftwareUpdateJob on success. +type SoftwareUpdateJobResponse struct { + // Retrieve a single application. + App *SoftwareUpdateJobApp `json:"app"` +} + +// GetApp returns SoftwareUpdateJobResponse.App, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobResponse) GetApp() *SoftwareUpdateJobApp { return v.App } + +// StartCustomDeployResponse is returned by StartCustomDeploy on success. +type StartCustomDeployResponse struct { + // Start a custom deploy on an environment. + StartCustomDeploy *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload `json:"startCustomDeploy"` +} + +// GetStartCustomDeploy returns StartCustomDeployResponse.StartCustomDeploy, and is useful for accessing the field via an interface. +func (v *StartCustomDeployResponse) GetStartCustomDeploy() *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload { + return v.StartCustomDeploy +} + +// StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload includes the requested fields of the GraphQL type AppEnvironmentCustomDeployPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a custom deploy. +type StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload struct { + // Whether the custom deploy request succeeded. + Success *bool `json:"success"` + // A human-readable message about the deploy request. + Message *string `json:"message"` +} + +// GetSuccess returns StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload.Success, and is useful for accessing the field via an interface. +func (v *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload) GetSuccess() *bool { + return v.Success +} + +// GetMessage returns StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload.Message, and is useful for accessing the field via an interface. +func (v *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload) GetMessage() *string { + return v.Message +} + +// StartImportResponse is returned by StartImport on success. +type StartImportResponse struct { + // Start importing data into an environment. + StartImport *StartImportStartImportAppEnvironmentImportPayload `json:"startImport"` +} + +// GetStartImport returns StartImportResponse.StartImport, and is useful for accessing the field via an interface. +func (v *StartImportResponse) GetStartImport() *StartImportStartImportAppEnvironmentImportPayload { + return v.StartImport +} + +// StartImportStartImportAppEnvironmentImportPayload includes the requested fields of the GraphQL type AppEnvironmentImportPayload. +// The GraphQL type's documentation follows. +// +// The result of starting an environment import. +type StartImportStartImportAppEnvironmentImportPayload struct { + // The application that owns the environment. + App *StartImportStartImportAppEnvironmentImportPayloadApp `json:"app"` + // A human-readable result message. + Message *string `json:"message"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetApp returns StartImportStartImportAppEnvironmentImportPayload.App, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetApp() *StartImportStartImportAppEnvironmentImportPayloadApp { + return v.App +} + +// GetMessage returns StartImportStartImportAppEnvironmentImportPayload.Message, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetMessage() *string { return v.Message } + +// GetSuccess returns StartImportStartImportAppEnvironmentImportPayload.Success, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetSuccess() *bool { return v.Success } + +// StartImportStartImportAppEnvironmentImportPayloadApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type StartImportStartImportAppEnvironmentImportPayloadApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` +} + +// GetId returns StartImportStartImportAppEnvironmentImportPayloadApp.Id, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayloadApp) GetId() *int64 { return v.Id } + +// GetName returns StartImportStartImportAppEnvironmentImportPayloadApp.Name, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayloadApp) GetName() *string { return v.Name } + +// StartLiveBackupCopyResponse is returned by StartLiveBackupCopy on success. +type StartLiveBackupCopyResponse struct { + // Start a live backup copy. + StartLiveBackupCopy *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload `json:"startLiveBackupCopy"` +} + +// GetStartLiveBackupCopy returns StartLiveBackupCopyResponse.StartLiveBackupCopy, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyResponse) GetStartLiveBackupCopy() *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload { + return v.StartLiveBackupCopy +} + +// StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload includes the requested fields of the GraphQL type AppEnvironmentStartLiveBackupCopyPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a live backup copy. +type StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload struct { + // A human-readable result message. + Message *string `json:"message"` + // The live backup copy ID. + CopyId *string `json:"copyId"` +} + +// GetMessage returns StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload.Message, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload) GetMessage() *string { + return v.Message +} + +// GetCopyId returns StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload.CopyId, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload) GetCopyId() *string { + return v.CopyId +} + +// StartMediaImportResponse is returned by StartMediaImport on success. +type StartMediaImportResponse struct { + // Import media into an environment. + StartMediaImport *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload `json:"startMediaImport"` +} + +// GetStartMediaImport returns StartMediaImportResponse.StartMediaImport, and is useful for accessing the field via an interface. +func (v *StartMediaImportResponse) GetStartMediaImport() *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload { + return v.StartMediaImport +} + +// StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload includes the requested fields of the GraphQL type AppEnvironmentMediaImportPayload. +// The GraphQL type's documentation follows. +// +// Response payload for starting and fetching a Media Import +type StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload struct { + // The unique ID of the Application + ApplicationId *int64 `json:"applicationId"` + // The unique ID of the Environment + EnvironmentId *int64 `json:"environmentId"` + // Media Import Status + MediaImportStatus *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus `json:"mediaImportStatus"` +} + +// GetApplicationId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.ApplicationId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetApplicationId() *int64 { + return v.ApplicationId +} + +// GetEnvironmentId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.EnvironmentId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetMediaImportStatus returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.MediaImportStatus, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetMediaImportStatus() *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus { + return v.MediaImportStatus +} + +// StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatus. +// The GraphQL type's documentation follows. +// +// Current status of a Media Import +type StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The actual status of the Media Import + Status *string `json:"status"` +} + +// GetImportId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.ImportId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.SiteId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatus returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.Status, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetStatus() *string { + return v.Status +} + +// SyncEnvironmentResponse is returned by SyncEnvironment on success. +type SyncEnvironmentResponse struct { + // Trigger a sync for an application environment. + SyncEnvironment *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload `json:"syncEnvironment"` +} + +// GetSyncEnvironment returns SyncEnvironmentResponse.SyncEnvironment, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentResponse) GetSyncEnvironment() *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload { + return v.SyncEnvironment +} + +// SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload includes the requested fields of the GraphQL type AppEnvironmentSyncPayload. +// The GraphQL type's documentation follows. +// +// The result of triggering an environment sync. +type SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload struct { + // The environment being synced. + Environment *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment `json:"environment"` +} + +// GetEnvironment returns SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload.Environment, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload) GetEnvironment() *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment { + return v.Environment +} + +// SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` +} + +// GetId returns SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment) GetId() *int64 { + return v.Id +} + +// SyncPreviewApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SyncPreviewApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SyncPreviewAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SyncPreviewApp.Id, and is useful for accessing the field via an interface. +func (v *SyncPreviewApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns SyncPreviewApp.Environments, and is useful for accessing the field via an interface. +func (v *SyncPreviewApp) GetEnvironments() []*SyncPreviewAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SyncPreviewAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncPreviewAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // A preview of the next environment sync. + SyncPreview *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview `json:"syncPreview"` +} + +// GetId returns SyncPreviewAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSyncPreview returns SyncPreviewAppEnvironmentsAppEnvironment.SyncPreview, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironment) GetSyncPreview() *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview { + return v.SyncPreview +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview includes the requested fields of the GraphQL type AppEnvironmentSyncPreview. +// The GraphQL type's documentation follows. +// +// A preview of whether an environment can be synced. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview struct { + // Whether the environment can be synced. + CanSync *bool `json:"canSync"` + // The validation errors preventing sync. + Errors []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError `json:"errors"` + // The backup that will be used for sync. + Backup *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup `json:"backup"` + // The replacements that will be applied during sync. + Replacements []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement `json:"replacements"` +} + +// GetCanSync returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.CanSync, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetCanSync() *bool { return v.CanSync } + +// GetErrors returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Errors, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetErrors() []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError { + return v.Errors +} + +// GetBackup returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Backup, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetBackup() *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup { + return v.Backup +} + +// GetReplacements returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Replacements, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetReplacements() []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement { + return v.Replacements +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup includes the requested fields of the GraphQL type AppEnvironmentBackup. +// The GraphQL type's documentation follows. +// +// A lightweight backup summary for an environment. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup struct { + // When the backup was created. + CreatedAt *string `json:"createdAt"` +} + +// GetCreatedAt returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup.CreatedAt, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup) GetCreatedAt() *string { + return v.CreatedAt +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError includes the requested fields of the GraphQL type AppEnvironmentSyncError. +// The GraphQL type's documentation follows. +// +// A sync validation error. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError struct { + // The error message. + Message *string `json:"message"` +} + +// GetMessage returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError.Message, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError) GetMessage() *string { + return v.Message +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement includes the requested fields of the GraphQL type AppEnvironmentSyncReplacement. +// The GraphQL type's documentation follows. +// +// A string replacement that will be applied during sync. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement struct { + // The source value. + From *string `json:"from"` + // The replacement value. + To *string `json:"to"` +} + +// GetFrom returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement.From, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement) GetFrom() *string { + return v.From +} + +// GetTo returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement.To, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement) GetTo() *string { + return v.To +} + +// SyncPreviewResponse is returned by SyncPreview on success. +type SyncPreviewResponse struct { + // Retrieve a single application. + App *SyncPreviewApp `json:"app"` +} + +// GetApp returns SyncPreviewResponse.App, and is useful for accessing the field via an interface. +func (v *SyncPreviewResponse) GetApp() *SyncPreviewApp { return v.App } + +// SyncProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SyncProgressApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SyncProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SyncProgressApp.Id, and is useful for accessing the field via an interface. +func (v *SyncProgressApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns SyncProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *SyncProgressApp) GetEnvironments() []*SyncProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SyncProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The current sync progress for the environment. + SyncProgress *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress `json:"syncProgress"` +} + +// GetId returns SyncProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSyncProgress returns SyncProgressAppEnvironmentsAppEnvironment.SyncProgress, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironment) GetSyncProgress() *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress { + return v.SyncProgress +} + +// SyncProgressAppEnvironmentsAppEnvironmentSyncProgress includes the requested fields of the GraphQL type AppEnvironmentSyncProgress. +// The GraphQL type's documentation follows. +// +// Progress details for an environment sync. +type SyncProgressAppEnvironmentsAppEnvironmentSyncProgress struct { + // The overall sync status. + Status *string `json:"status"` + // The sync job ID. + Sync *int64 `json:"sync"` + // The individual sync steps. + Steps []*SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep `json:"steps"` +} + +// GetStatus returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Status, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetStatus() *string { return v.Status } + +// GetSync returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Sync, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetSync() *int64 { return v.Sync } + +// GetSteps returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Steps, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetSteps() []*SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep { + return v.Steps +} + +// SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep includes the requested fields of the GraphQL type AppEnvironmentSyncStep. +// The GraphQL type's documentation follows. +// +// A single step in an environment sync. +type SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep struct { + // The display name of the step. + Name *string `json:"name"` + // The step status. + Status *string `json:"status"` + // The step identifier. + Step *string `json:"step"` +} + +// GetName returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Name, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetName() *string { + return v.Name +} + +// GetStatus returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Status, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetStatus() *string { + return v.Status +} + +// GetStep returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Step, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetStep() *string { + return v.Step +} + +// SyncProgressResponse is returned by SyncProgress on success. +type SyncProgressResponse struct { + // Retrieve a single application. + App *SyncProgressApp `json:"app"` +} + +// GetApp returns SyncProgressResponse.App, and is useful for accessing the field via an interface. +func (v *SyncProgressResponse) GetApp() *SyncProgressApp { return v.App } + +// TriggerDatabaseBackupResponse is returned by TriggerDatabaseBackup on success. +type TriggerDatabaseBackupResponse struct { + // Trigger a database backup. + TriggerDatabaseBackup *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload `json:"triggerDatabaseBackup"` +} + +// GetTriggerDatabaseBackup returns TriggerDatabaseBackupResponse.TriggerDatabaseBackup, and is useful for accessing the field via an interface. +func (v *TriggerDatabaseBackupResponse) GetTriggerDatabaseBackup() *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload { + return v.TriggerDatabaseBackup +} + +// TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload includes the requested fields of the GraphQL type AppEnvironmentTriggerDBBackupPayload. +// The GraphQL type's documentation follows. +// +// The result of triggering a database backup. +type TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload struct { + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetSuccess returns TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload.Success, and is useful for accessing the field via an interface. +func (v *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload) GetSuccess() *bool { + return v.Success +} + +// TriggerWPCLICommandResponse is returned by TriggerWPCLICommand on success. +type TriggerWPCLICommandResponse struct { + // Execute a WP-CLI command on an environment. + TriggerWPCLICommandOnAppEnvironment *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload `json:"triggerWPCLICommandOnAppEnvironment"` +} + +// GetTriggerWPCLICommandOnAppEnvironment returns TriggerWPCLICommandResponse.TriggerWPCLICommandOnAppEnvironment, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandResponse) GetTriggerWPCLICommandOnAppEnvironment() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload { + return v.TriggerWPCLICommandOnAppEnvironment +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload includes the requested fields of the GraphQL type AppEnvironmentTriggerWPCLICommandPayload. +// The GraphQL type's documentation follows. +// +// Response from the Run WP-CLI Command mutation +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload struct { + // The token for authenticating the socket connection + InputToken *string `json:"inputToken"` + // The command that was executed + Command *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand `json:"command"` + // The SSH credentials for connecting to the command session. + SshAuthentication *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication `json:"sshAuthentication"` +} + +// GetInputToken returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.InputToken, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetInputToken() *string { + return v.InputToken +} + +// GetCommand returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.Command, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetCommand() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand { + return v.Command +} + +// GetSshAuthentication returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.SshAuthentication, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetSshAuthentication() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication { + return v.SshAuthentication +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand includes the requested fields of the GraphQL type WPCLICommand. +// The GraphQL type's documentation follows. +// +// A WP-CLI command executed on an application environment. +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand struct { + // The GUID for the command. + Guid *string `json:"guid"` +} + +// GetGuid returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand.Guid, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand) GetGuid() *string { + return v.Guid +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication includes the requested fields of the GraphQL type WPCliSSHAuthentication. +// The GraphQL type's documentation follows. +// +// SSH credentials for running a WP-CLI command. +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication struct { + // The SSH host. + Host string `json:"host"` + // The SSH port. + Port string `json:"port"` + // The SSH username. + Username string `json:"username"` + // The private key used for authentication. + PrivateKey string `json:"privateKey"` + // The passphrase for the private key. + Passphrase string `json:"passphrase"` +} + +// GetHost returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Host, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetHost() string { + return v.Host +} + +// GetPort returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Port, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPort() string { + return v.Port +} + +// GetUsername returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Username, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetUsername() string { + return v.Username +} + +// GetPrivateKey returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.PrivateKey, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPrivateKey() string { + return v.PrivateKey +} + +// GetPassphrase returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Passphrase, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPassphrase() string { + return v.Passphrase +} + +// UpdateDefensiveModeConfigResponse is returned by UpdateDefensiveModeConfig on success. +type UpdateDefensiveModeConfigResponse struct { + // Update defensive mode configuration. + UpdateDefensiveModeConfig *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload `json:"updateDefensiveModeConfig"` +} + +// GetUpdateDefensiveModeConfig returns UpdateDefensiveModeConfigResponse.UpdateDefensiveModeConfig, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigResponse) GetUpdateDefensiveModeConfig() *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload { + return v.UpdateDefensiveModeConfig +} + +// UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload includes the requested fields of the GraphQL type AppEnvironmentDefensiveModeOperationResultPayload. +// The GraphQL type's documentation follows. +// +// The result of a defensive mode operation. +type UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // A human-readable result message. + Message string `json:"message"` +} + +// GetSuccess returns UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload.Success, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload) GetSuccess() bool { + return v.Success +} + +// GetMessage returns UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload.Message, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload) GetMessage() string { + return v.Message +} + +// UpdateDefensiveModeStatusResponse is returned by UpdateDefensiveModeStatus on success. +type UpdateDefensiveModeStatusResponse struct { + // Enable or disable defensive mode. + UpdateDefensiveModeStatus *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload `json:"updateDefensiveModeStatus"` +} + +// GetUpdateDefensiveModeStatus returns UpdateDefensiveModeStatusResponse.UpdateDefensiveModeStatus, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusResponse) GetUpdateDefensiveModeStatus() *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload { + return v.UpdateDefensiveModeStatus +} + +// UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload includes the requested fields of the GraphQL type AppEnvironmentDefensiveModeOperationResultPayload. +// The GraphQL type's documentation follows. +// +// The result of a defensive mode operation. +type UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // A human-readable result message. + Message string `json:"message"` +} + +// GetSuccess returns UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload.Success, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload) GetSuccess() bool { + return v.Success +} + +// GetMessage returns UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload.Message, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload) GetMessage() string { + return v.Message +} + +// UpdateSoftwareSettingsResponse is returned by UpdateSoftwareSettings on success. +type UpdateSoftwareSettingsResponse struct { + // Update software settings for an application environment. + UpdateSoftwareSettings *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings `json:"updateSoftwareSettings"` +} + +// GetUpdateSoftwareSettings returns UpdateSoftwareSettingsResponse.UpdateSoftwareSettings, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsResponse) GetUpdateSoftwareSettings() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings { + return v.UpdateSoftwareSettings +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings struct { + // The WordPress software settings. + Wordpress *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` + // The PHP software settings. + Php *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The mu-plugins software settings. + Muplugins *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware `json:"muplugins"` + // The Node.js software settings. + Nodejs *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware `json:"nodejs"` +} + +// GetWordpress returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetWordpress() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// GetPhp returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetPhp() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetMuplugins returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Muplugins, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetMuplugins() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware { + return v.Muplugins +} + +// GetNodejs returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Nodejs, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetNodejs() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware { + return v.Nodejs +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// Input for validating custom deploy access. +type ValidateCustomDeployAccessInput struct { + // The application identifier to validate. + App string `json:"app"` + // The environment identifier to validate. + Env string `json:"env"` +} + +// GetApp returns ValidateCustomDeployAccessInput.App, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessInput) GetApp() string { return v.App } + +// GetEnv returns ValidateCustomDeployAccessInput.Env, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessInput) GetEnv() string { return v.Env } + +// ValidateCustomDeployAccessResponse is returned by ValidateCustomDeployAccess on success. +type ValidateCustomDeployAccessResponse struct { + // Validate custom deploy access for an application and environment. + ValidateCustomDeployAccess *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload `json:"validateCustomDeployAccess"` +} + +// GetValidateCustomDeployAccess returns ValidateCustomDeployAccessResponse.ValidateCustomDeployAccess, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessResponse) GetValidateCustomDeployAccess() *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload { + return v.ValidateCustomDeployAccess +} + +// ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload includes the requested fields of the GraphQL type ValidateCustomDeployAccessPayload. +// The GraphQL type's documentation follows. +// +// The result of validating custom deploy access. +type ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload struct { + // Whether the custom deploy access is valid. + Success *bool `json:"success"` + // The resolved application ID. + AppId *int64 `json:"appId"` + // The resolved environment ID. + EnvId *int64 `json:"envId"` + // The resolved environment type. + EnvType *string `json:"envType"` + // The resolved unique environment label. + EnvUniqueLabel *string `json:"envUniqueLabel"` + // The primary domain name for the environment. + PrimaryDomainName *string `json:"primaryDomainName"` + // Whether the environment is launched. + Launched *bool `json:"launched"` +} + +// GetSuccess returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.Success, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetSuccess() *bool { + return v.Success +} + +// GetAppId returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.AppId, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetAppId() *int64 { + return v.AppId +} + +// GetEnvId returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvId, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvId() *int64 { + return v.EnvId +} + +// GetEnvType returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvType, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvType() *string { + return v.EnvType +} + +// GetEnvUniqueLabel returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvUniqueLabel, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvUniqueLabel() *string { + return v.EnvUniqueLabel +} + +// GetPrimaryDomainName returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.PrimaryDomainName, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetPrimaryDomainName() *string { + return v.PrimaryDomainName +} + +// GetLaunched returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.Launched, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetLaunched() *bool { + return v.Launched +} + +// WPEnvInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type WPEnvInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*WPEnvInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns WPEnvInfoApp.Id, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetId() *int64 { return v.Id } + +// GetName returns WPEnvInfoApp.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetName() *string { return v.Name } + +// GetTypeId returns WPEnvInfoApp.TypeId, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns WPEnvInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetEnvironments() []*WPEnvInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// WPEnvInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type WPEnvInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // The strategy used to execute WP-CLI commands. + WpcliStrategy *AppEnvironmentWPCliStrategy `json:"wpcliStrategy"` + // The primary domain for the environment. + PrimaryDomain *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` +} + +// GetId returns WPEnvInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns WPEnvInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns WPEnvInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns WPEnvInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetWpcliStrategy returns WPEnvInfoAppEnvironmentsAppEnvironment.WpcliStrategy, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetWpcliStrategy() *AppEnvironmentWPCliStrategy { + return v.WpcliStrategy +} + +// GetPrimaryDomain returns WPEnvInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// WPEnvInfoResponse is returned by WPEnvInfo on success. +type WPEnvInfoResponse struct { + // Retrieve a single application. + App *WPEnvInfoApp `json:"app"` +} + +// GetApp returns WPEnvInfoResponse.App, and is useful for accessing the field via an interface. +func (v *WPEnvInfoResponse) GetApp() *WPEnvInfoApp { return v.App } + +// __AbortMediaImportInput is used internally by genqlient +type __AbortMediaImportInput struct { + Input *AppEnvironmentAbortMediaImportInput `json:"input,omitempty"` +} + +// GetInput returns __AbortMediaImportInput.Input, and is useful for accessing the field via an interface. +func (v *__AbortMediaImportInput) GetInput() *AppEnvironmentAbortMediaImportInput { return v.Input } + +// __AddEnvironmentVariableInput is used internally by genqlient +type __AddEnvironmentVariableInput struct { + Input *EnvironmentVariableInput `json:"input,omitempty"` +} + +// GetInput returns __AddEnvironmentVariableInput.Input, and is useful for accessing the field via an interface. +func (v *__AddEnvironmentVariableInput) GetInput() *EnvironmentVariableInput { return v.Input } + +// __AppBackupAndJobStatusInput is used internally by genqlient +type __AppBackupAndJobStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __AppBackupAndJobStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppBackupAndJobStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __AppBackupAndJobStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppBackupAndJobStatusInput) GetEnvId() int64 { return v.EnvId } + +// __AppBackupJobStatusInput is used internally by genqlient +type __AppBackupJobStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __AppBackupJobStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppBackupJobStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __AppBackupJobStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppBackupJobStatusInput) GetEnvId() int64 { return v.EnvId } + +// __AppGetByIDInput is used internally by genqlient +type __AppGetByIDInput struct { + Id int64 `json:"id"` +} + +// GetId returns __AppGetByIDInput.Id, and is useful for accessing the field via an interface. +func (v *__AppGetByIDInput) GetId() int64 { return v.Id } + +// __AppGetByNameInput is used internally by genqlient +type __AppGetByNameInput struct { + Name string `json:"name"` +} + +// GetName returns __AppGetByNameInput.Name, and is useful for accessing the field via an interface. +func (v *__AppGetByNameInput) GetName() string { return v.Name } + +// __AppListInput is used internally by genqlient +type __AppListInput struct { + First *int64 `json:"first"` + After *string `json:"after"` +} + +// GetFirst returns __AppListInput.First, and is useful for accessing the field via an interface. +func (v *__AppListInput) GetFirst() *int64 { return v.First } + +// GetAfter returns __AppListInput.After, and is useful for accessing the field via an interface. +func (v *__AppListInput) GetAfter() *string { return v.After } + +// __AppMappedDomainsInput is used internally by genqlient +type __AppMappedDomainsInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __AppMappedDomainsInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppMappedDomainsInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __AppMappedDomainsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppMappedDomainsInput) GetEnvId() *int64 { return v.EnvId } + +// __AppMultiSiteCheckInput is used internally by genqlient +type __AppMultiSiteCheckInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __AppMultiSiteCheckInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppMultiSiteCheckInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __AppMultiSiteCheckInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppMultiSiteCheckInput) GetEnvId() *int64 { return v.EnvId } + +// __BackupDBCopyInput is used internally by genqlient +type __BackupDBCopyInput struct { + Input *AppEnvironmentStartDBBackupCopyInput `json:"input,omitempty"` +} + +// GetInput returns __BackupDBCopyInput.Input, and is useful for accessing the field via an interface. +func (v *__BackupDBCopyInput) GetInput() *AppEnvironmentStartDBBackupCopyInput { return v.Input } + +// __DeleteEnvironmentVariableInput is used internally by genqlient +type __DeleteEnvironmentVariableInput struct { + Input *EnvironmentVariableInput `json:"input,omitempty"` +} + +// GetInput returns __DeleteEnvironmentVariableInput.Input, and is useful for accessing the field via an interface. +func (v *__DeleteEnvironmentVariableInput) GetInput() *EnvironmentVariableInput { return v.Input } + +// __DevEnvAppInfoInput is used internally by genqlient +type __DevEnvAppInfoInput struct { + AppId int64 `json:"appId"` +} + +// GetAppId returns __DevEnvAppInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__DevEnvAppInfoInput) GetAppId() int64 { return v.AppId } + +// __DevEnvSyncSitesInput is used internally by genqlient +type __DevEnvSyncSitesInput struct { + AppId int64 `json:"appId"` + EnvironmentId int64 `json:"environmentId"` + After *string `json:"after"` + First int64 `json:"first"` +} + +// GetAppId returns __DevEnvSyncSitesInput.AppId, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetAppId() int64 { return v.AppId } + +// GetEnvironmentId returns __DevEnvSyncSitesInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetAfter returns __DevEnvSyncSitesInput.After, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetAfter() *string { return v.After } + +// GetFirst returns __DevEnvSyncSitesInput.First, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetFirst() int64 { return v.First } + +// __EnablePhpMyAdminInput is used internally by genqlient +type __EnablePhpMyAdminInput struct { + Input *EnablePhpMyAdminInput `json:"input,omitempty"` +} + +// GetInput returns __EnablePhpMyAdminInput.Input, and is useful for accessing the field via an interface. +func (v *__EnablePhpMyAdminInput) GetInput() *EnablePhpMyAdminInput { return v.Input } + +// __GenerateDBBackupCopyUrlInput is used internally by genqlient +type __GenerateDBBackupCopyUrlInput struct { + Input *AppEnvironmentGenerateDBBackupCopyUrlInput `json:"input,omitempty"` +} + +// GetInput returns __GenerateDBBackupCopyUrlInput.Input, and is useful for accessing the field via an interface. +func (v *__GenerateDBBackupCopyUrlInput) GetInput() *AppEnvironmentGenerateDBBackupCopyUrlInput { + return v.Input +} + +// __GenerateLiveBackupCopyDownloadURLInput is used internally by genqlient +type __GenerateLiveBackupCopyDownloadURLInput struct { + Input *AppEnvironmentLiveBackupCopyDownloadURLInput `json:"input,omitempty"` +} + +// GetInput returns __GenerateLiveBackupCopyDownloadURLInput.Input, and is useful for accessing the field via an interface. +func (v *__GenerateLiveBackupCopyDownloadURLInput) GetInput() *AppEnvironmentLiveBackupCopyDownloadURLInput { + return v.Input +} + +// __GeneratePhpMyAdminAccessInput is used internally by genqlient +type __GeneratePhpMyAdminAccessInput struct { + Input *GeneratePhpMyAdminAccessInput `json:"input,omitempty"` +} + +// GetInput returns __GeneratePhpMyAdminAccessInput.Input, and is useful for accessing the field via an interface. +func (v *__GeneratePhpMyAdminAccessInput) GetInput() *GeneratePhpMyAdminAccessInput { return v.Input } + +// __GetAppLogsInput is used internally by genqlient +type __GetAppLogsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + LogType AppEnvironmentLogType `json:"logType"` + Limit int64 `json:"limit"` + After *string `json:"after"` +} + +// GetAppId returns __GetAppLogsInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetAppLogsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetEnvId() int64 { return v.EnvId } + +// GetLogType returns __GetAppLogsInput.LogType, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetLogType() AppEnvironmentLogType { return v.LogType } + +// GetLimit returns __GetAppLogsInput.Limit, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetLimit() int64 { return v.Limit } + +// GetAfter returns __GetAppLogsInput.After, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetAfter() *string { return v.After } + +// __GetAppSlowlogsInput is used internally by genqlient +type __GetAppSlowlogsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + Limit int64 `json:"limit"` + After *string `json:"after"` +} + +// GetAppId returns __GetAppSlowlogsInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetAppSlowlogsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetEnvId() int64 { return v.EnvId } + +// GetLimit returns __GetAppSlowlogsInput.Limit, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetLimit() int64 { return v.Limit } + +// GetAfter returns __GetAppSlowlogsInput.After, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetAfter() *string { return v.After } + +// __GetEnvironmentVariablesInput is used internally by genqlient +type __GetEnvironmentVariablesInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __GetEnvironmentVariablesInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetEnvironmentVariablesInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesInput) GetEnvId() int64 { return v.EnvId } + +// __GetEnvironmentVariablesWithValuesInput is used internally by genqlient +type __GetEnvironmentVariablesWithValuesInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __GetEnvironmentVariablesWithValuesInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesWithValuesInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetEnvironmentVariablesWithValuesInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesWithValuesInput) GetEnvId() int64 { return v.EnvId } + +// __ImportSQLEnvInfoInput is used internally by genqlient +type __ImportSQLEnvInfoInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __ImportSQLEnvInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__ImportSQLEnvInfoInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __ImportSQLEnvInfoInput.EnvId, and is useful for accessing the field via an interface. +func (v *__ImportSQLEnvInfoInput) GetEnvId() int64 { return v.EnvId } + +// __ImportSQLProgressInput is used internally by genqlient +type __ImportSQLProgressInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __ImportSQLProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__ImportSQLProgressInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __ImportSQLProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__ImportSQLProgressInput) GetEnvId() *int64 { return v.EnvId } + +// __MediaImportProgressInput is used internally by genqlient +type __MediaImportProgressInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __MediaImportProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__MediaImportProgressInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __MediaImportProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__MediaImportProgressInput) GetEnvId() *int64 { return v.EnvId } + +// __PhpMyAdminStatusInput is used internally by genqlient +type __PhpMyAdminStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __PhpMyAdminStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__PhpMyAdminStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __PhpMyAdminStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__PhpMyAdminStatusInput) GetEnvId() int64 { return v.EnvId } + +// __PurgePageCacheInput is used internally by genqlient +type __PurgePageCacheInput struct { + Input *PurgePageCacheInput `json:"input,omitempty"` +} + +// GetInput returns __PurgePageCacheInput.Input, and is useful for accessing the field via an interface. +func (v *__PurgePageCacheInput) GetInput() *PurgePageCacheInput { return v.Input } + +// __ResolveAppByIDInput is used internally by genqlient +type __ResolveAppByIDInput struct { + Id int64 `json:"id"` +} + +// GetId returns __ResolveAppByIDInput.Id, and is useful for accessing the field via an interface. +func (v *__ResolveAppByIDInput) GetId() int64 { return v.Id } + +// __ResolveAppByNameInput is used internally by genqlient +type __ResolveAppByNameInput struct { + Name string `json:"name"` +} + +// GetName returns __ResolveAppByNameInput.Name, and is useful for accessing the field via an interface. +func (v *__ResolveAppByNameInput) GetName() string { return v.Name } + +// __SoftwareSettingsInput is used internally by genqlient +type __SoftwareSettingsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SoftwareSettingsInput.AppId, and is useful for accessing the field via an interface. +func (v *__SoftwareSettingsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SoftwareSettingsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SoftwareSettingsInput) GetEnvId() int64 { return v.EnvId } + +// __SoftwareUpdateJobInput is used internally by genqlient +type __SoftwareUpdateJobInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SoftwareUpdateJobInput.AppId, and is useful for accessing the field via an interface. +func (v *__SoftwareUpdateJobInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SoftwareUpdateJobInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SoftwareUpdateJobInput) GetEnvId() int64 { return v.EnvId } + +// __StartCustomDeployInput is used internally by genqlient +type __StartCustomDeployInput struct { + Input *AppEnvironmentCustomDeployInput `json:"input,omitempty"` +} + +// GetInput returns __StartCustomDeployInput.Input, and is useful for accessing the field via an interface. +func (v *__StartCustomDeployInput) GetInput() *AppEnvironmentCustomDeployInput { return v.Input } + +// __StartImportInput is used internally by genqlient +type __StartImportInput struct { + Input *AppEnvironmentImportInput `json:"input,omitempty"` +} + +// GetInput returns __StartImportInput.Input, and is useful for accessing the field via an interface. +func (v *__StartImportInput) GetInput() *AppEnvironmentImportInput { return v.Input } + +// __StartLiveBackupCopyInput is used internally by genqlient +type __StartLiveBackupCopyInput struct { + Input *LiveBackupCopyConfigInput `json:"input,omitempty"` +} + +// GetInput returns __StartLiveBackupCopyInput.Input, and is useful for accessing the field via an interface. +func (v *__StartLiveBackupCopyInput) GetInput() *LiveBackupCopyConfigInput { return v.Input } + +// __StartMediaImportInput is used internally by genqlient +type __StartMediaImportInput struct { + Input *AppEnvironmentStartMediaImportInput `json:"input,omitempty"` +} + +// GetInput returns __StartMediaImportInput.Input, and is useful for accessing the field via an interface. +func (v *__StartMediaImportInput) GetInput() *AppEnvironmentStartMediaImportInput { return v.Input } + +// __SyncEnvironmentInput is used internally by genqlient +type __SyncEnvironmentInput struct { + Input *AppEnvironmentSyncInput `json:"input,omitempty"` +} + +// GetInput returns __SyncEnvironmentInput.Input, and is useful for accessing the field via an interface. +func (v *__SyncEnvironmentInput) GetInput() *AppEnvironmentSyncInput { return v.Input } + +// __SyncPreviewInput is used internally by genqlient +type __SyncPreviewInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SyncPreviewInput.AppId, and is useful for accessing the field via an interface. +func (v *__SyncPreviewInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SyncPreviewInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SyncPreviewInput) GetEnvId() int64 { return v.EnvId } + +// __SyncProgressInput is used internally by genqlient +type __SyncProgressInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SyncProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__SyncProgressInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SyncProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SyncProgressInput) GetEnvId() int64 { return v.EnvId } + +// __TriggerDatabaseBackupInput is used internally by genqlient +type __TriggerDatabaseBackupInput struct { + Input *AppEnvironmentTriggerDBBackupInput `json:"input,omitempty"` +} + +// GetInput returns __TriggerDatabaseBackupInput.Input, and is useful for accessing the field via an interface. +func (v *__TriggerDatabaseBackupInput) GetInput() *AppEnvironmentTriggerDBBackupInput { return v.Input } + +// __TriggerWPCLICommandInput is used internally by genqlient +type __TriggerWPCLICommandInput struct { + Input *AppEnvironmentTriggerWPCLICommandInput `json:"input,omitempty"` +} + +// GetInput returns __TriggerWPCLICommandInput.Input, and is useful for accessing the field via an interface. +func (v *__TriggerWPCLICommandInput) GetInput() *AppEnvironmentTriggerWPCLICommandInput { + return v.Input +} + +// __UpdateDefensiveModeConfigInput is used internally by genqlient +type __UpdateDefensiveModeConfigInput struct { + Input *AppEnvironmentDefensiveModeConfigInput `json:"input,omitempty"` +} + +// GetInput returns __UpdateDefensiveModeConfigInput.Input, and is useful for accessing the field via an interface. +func (v *__UpdateDefensiveModeConfigInput) GetInput() *AppEnvironmentDefensiveModeConfigInput { + return v.Input +} + +// __UpdateDefensiveModeStatusInput is used internally by genqlient +type __UpdateDefensiveModeStatusInput struct { + Input *AppEnvironmentDefensiveModeUpdateStatusInput `json:"input,omitempty"` +} + +// GetInput returns __UpdateDefensiveModeStatusInput.Input, and is useful for accessing the field via an interface. +func (v *__UpdateDefensiveModeStatusInput) GetInput() *AppEnvironmentDefensiveModeUpdateStatusInput { + return v.Input +} + +// __UpdateSoftwareSettingsInput is used internally by genqlient +type __UpdateSoftwareSettingsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + Component string `json:"component"` + Version string `json:"version"` +} + +// GetAppId returns __UpdateSoftwareSettingsInput.AppId, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __UpdateSoftwareSettingsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetEnvId() int64 { return v.EnvId } + +// GetComponent returns __UpdateSoftwareSettingsInput.Component, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetComponent() string { return v.Component } + +// GetVersion returns __UpdateSoftwareSettingsInput.Version, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetVersion() string { return v.Version } + +// __ValidateCustomDeployAccessInput is used internally by genqlient +type __ValidateCustomDeployAccessInput struct { + Input *ValidateCustomDeployAccessInput `json:"input,omitempty"` +} + +// GetInput returns __ValidateCustomDeployAccessInput.Input, and is useful for accessing the field via an interface. +func (v *__ValidateCustomDeployAccessInput) GetInput() *ValidateCustomDeployAccessInput { + return v.Input +} + +// __WPEnvInfoInput is used internally by genqlient +type __WPEnvInfoInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __WPEnvInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__WPEnvInfoInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __WPEnvInfoInput.EnvId, and is useful for accessing the field via an interface. +func (v *__WPEnvInfoInput) GetEnvId() int64 { return v.EnvId } + +// The mutation executed by AbortMediaImport. +const AbortMediaImport_Operation = ` +mutation AbortMediaImport ($input: AppEnvironmentAbortMediaImportInput) { + abortMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatusChange { + importId + siteId + statusFrom + statusTo + } + } +} +` + +func AbortMediaImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentAbortMediaImportInput, +) (data_ *AbortMediaImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AbortMediaImport", + Query: AbortMediaImport_Operation, + Variables: &__AbortMediaImportInput{ + Input: input, + }, + } + + data_ = &AbortMediaImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by AddEnvironmentVariable. +const AddEnvironmentVariable_Operation = ` +mutation AddEnvironmentVariable ($input: EnvironmentVariableInput!) { + addEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} +` + +func AddEnvironmentVariable( + ctx_ context.Context, + client_ graphql.Client, + input *EnvironmentVariableInput, +) (data_ *AddEnvironmentVariableResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AddEnvironmentVariable", + Query: AddEnvironmentVariable_Operation, + Variables: &__AddEnvironmentVariableInput{ + Input: input, + }, + } + + data_ = &AddEnvironmentVariableResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppBackupAndJobStatus. +const AppBackupAndJobStatus_Operation = ` +query AppBackupAndJobStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + backupsSqlDumpTool + latestBackup { + id + type + size + filename + sqlDumpTool + createdAt + } + jobs(jobTypes: [db_backup_copy]) { + __typename + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + steps { + id + name + step + status + } + } + } + } + } +} +` + +func AppBackupAndJobStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *AppBackupAndJobStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppBackupAndJobStatus", + Query: AppBackupAndJobStatus_Operation, + Variables: &__AppBackupAndJobStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppBackupAndJobStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppBackupJobStatus. +const AppBackupJobStatus_Operation = ` +query AppBackupJobStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + jobs(jobTypes: [db_backup]) { + __typename + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + } + } + } + } +} +` + +func AppBackupJobStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *AppBackupJobStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppBackupJobStatus", + Query: AppBackupJobStatus_Operation, + Variables: &__AppBackupJobStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppBackupJobStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppGetByID. +const AppGetByID_Operation = ` +query AppGetByID ($id: Int!) { + app(id: $id) { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } +} +` + +func AppGetByID( + ctx_ context.Context, + client_ graphql.Client, + id int64, +) (data_ *AppGetByIDResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppGetByID", + Query: AppGetByID_Operation, + Variables: &__AppGetByIDInput{ + Id: id, + }, + } + + data_ = &AppGetByIDResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppGetByName. +const AppGetByName_Operation = ` +query AppGetByName ($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } + } +} +` + +func AppGetByName( + ctx_ context.Context, + client_ graphql.Client, + name string, +) (data_ *AppGetByNameResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppGetByName", + Query: AppGetByName_Operation, + Variables: &__AppGetByNameInput{ + Name: name, + }, + } + + data_ = &AppGetByNameResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppList. +const AppList_Operation = ` +query AppList ($first: Int, $after: String) { + apps(first: $first, after: $after) { + total + nextCursor + edges { + ... AppBasic + } + } +} +fragment AppBasic on App { + id + name + repo +} +` + +func AppList( + ctx_ context.Context, + client_ graphql.Client, + first *int64, + after *string, +) (data_ *AppListResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppList", + Query: AppList_Operation, + Variables: &__AppListInput{ + First: first, + After: after, + }, + } + + data_ = &AppListResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppMappedDomains. +const AppMappedDomains_Operation = ` +query AppMappedDomains ($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + environments(id: $envId) { + uniqueLabel + isMultisite + domains { + nodes { + name + isPrimary + } + } + } + } +} +` + +func AppMappedDomains( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *AppMappedDomainsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppMappedDomains", + Query: AppMappedDomains_Operation, + Variables: &__AppMappedDomainsInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppMappedDomainsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppMultiSiteCheck. +const AppMultiSiteCheck_Operation = ` +query AppMultiSiteCheck ($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + repo + environments(id: $envId) { + id + appId + name + type + isMultisite + isSubdirectoryMultisite + } + } +} +` + +func AppMultiSiteCheck( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *AppMultiSiteCheckResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppMultiSiteCheck", + Query: AppMultiSiteCheck_Operation, + Variables: &__AppMultiSiteCheckInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppMultiSiteCheckResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by BackupDBCopy. +const BackupDBCopy_Operation = ` +mutation BackupDBCopy ($input: AppEnvironmentStartDBBackupCopyInput) { + startDBBackupCopy(input: $input) { + message + success + } +} +` + +func BackupDBCopy( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentStartDBBackupCopyInput, +) (data_ *BackupDBCopyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "BackupDBCopy", + Query: BackupDBCopy_Operation, + Variables: &__BackupDBCopyInput{ + Input: input, + }, + } + + data_ = &BackupDBCopyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by DeleteEnvironmentVariable. +const DeleteEnvironmentVariable_Operation = ` +mutation DeleteEnvironmentVariable ($input: EnvironmentVariableInput!) { + deleteEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} +` + +func DeleteEnvironmentVariable( + ctx_ context.Context, + client_ graphql.Client, + input *EnvironmentVariableInput, +) (data_ *DeleteEnvironmentVariableResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeleteEnvironmentVariable", + Query: DeleteEnvironmentVariable_Operation, + Variables: &__DeleteEnvironmentVariableInput{ + Input: input, + }, + } + + data_ = &DeleteEnvironmentVariableResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by DevEnvAppInfo. +const DevEnvAppInfo_Operation = ` +query DevEnvAppInfo ($appId: Int!) { + app(id: $appId) { + id + name + environments { + id + appId + name + type + isMultisite + primaryDomain { + name + } + environmentVariables { + nodes { + name + } + } + softwareSettings { + php { + current { + version + } + } + wordpress { + current { + version + } + } + } + } + } +} +` + +// dev-env create @app.env pre-population. Node source: +// getApplicationInformation — src/lib/dev-environment/dev-environment-core.ts:735 +// getOptionsFromAppInfo — src/lib/dev-environment/dev-environment-cli.ts:257 +// Fetches all environments (no useful server-side filter; the env is picked +// client-side by type) with the fields that seed the wizard defaults. +func DevEnvAppInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, +) (data_ *DevEnvAppInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DevEnvAppInfo", + Query: DevEnvAppInfo_Operation, + Variables: &__DevEnvAppInfoInput{ + AppId: appId, + }, + } + + data_ = &DevEnvAppInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by DevEnvSyncSites. +const DevEnvSyncSites_Operation = ` +query DevEnvSyncSites ($appId: Int!, $environmentId: Int!, $after: String, $first: Int!) { + app(id: $appId) { + environments(id: $environmentId) { + wpSitesSDS(after: $after, first: $first) { + total + nextCursor + nodes { + blogId + homeUrl + siteUrl + } + } + } + } +} +` + +func DevEnvSyncSites( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + environmentId int64, + after *string, + first int64, +) (data_ *DevEnvSyncSitesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DevEnvSyncSites", + Query: DevEnvSyncSites_Operation, + Variables: &__DevEnvSyncSitesInput{ + AppId: appId, + EnvironmentId: environmentId, + After: after, + First: first, + }, + } + + data_ = &DevEnvSyncSitesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by EnablePhpMyAdmin. +const EnablePhpMyAdmin_Operation = ` +mutation EnablePhpMyAdmin ($input: EnablePhpMyAdminInput!) { + enablePHPMyAdmin(input: $input) { + success + } +} +` + +func EnablePhpMyAdmin( + ctx_ context.Context, + client_ graphql.Client, + input *EnablePhpMyAdminInput, +) (data_ *EnablePhpMyAdminResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "EnablePhpMyAdmin", + Query: EnablePhpMyAdmin_Operation, + Variables: &__EnablePhpMyAdminInput{ + Input: input, + }, + } + + data_ = &EnablePhpMyAdminResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GenerateDBBackupCopyUrl. +const GenerateDBBackupCopyUrl_Operation = ` +mutation GenerateDBBackupCopyUrl ($input: AppEnvironmentGenerateDBBackupCopyUrlInput) { + generateDBBackupCopyUrl(input: $input) { + url + success + } +} +` + +func GenerateDBBackupCopyUrl( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentGenerateDBBackupCopyUrlInput, +) (data_ *GenerateDBBackupCopyUrlResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GenerateDBBackupCopyUrl", + Query: GenerateDBBackupCopyUrl_Operation, + Variables: &__GenerateDBBackupCopyUrlInput{ + Input: input, + }, + } + + data_ = &GenerateDBBackupCopyUrlResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GenerateLiveBackupCopyDownloadURL. +const GenerateLiveBackupCopyDownloadURL_Operation = ` +mutation GenerateLiveBackupCopyDownloadURL ($input: AppEnvironmentLiveBackupCopyDownloadURLInput!) { + generateLiveBackupCopyDownloadURL(input: $input) { + success + url + processing + size + } +} +` + +func GenerateLiveBackupCopyDownloadURL( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentLiveBackupCopyDownloadURLInput, +) (data_ *GenerateLiveBackupCopyDownloadURLResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GenerateLiveBackupCopyDownloadURL", + Query: GenerateLiveBackupCopyDownloadURL_Operation, + Variables: &__GenerateLiveBackupCopyDownloadURLInput{ + Input: input, + }, + } + + data_ = &GenerateLiveBackupCopyDownloadURLResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GeneratePhpMyAdminAccess. +const GeneratePhpMyAdminAccess_Operation = ` +mutation GeneratePhpMyAdminAccess ($input: GeneratePhpMyAdminAccessInput!) { + generatePHPMyAdminAccess(input: $input) { + url + } +} +` + +func GeneratePhpMyAdminAccess( + ctx_ context.Context, + client_ graphql.Client, + input *GeneratePhpMyAdminAccessInput, +) (data_ *GeneratePhpMyAdminAccessResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GeneratePhpMyAdminAccess", + Query: GeneratePhpMyAdminAccess_Operation, + Variables: &__GeneratePhpMyAdminAccessInput{ + Input: input, + }, + } + + data_ = &GeneratePhpMyAdminAccessResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetAppLogs. +const GetAppLogs_Operation = ` +query GetAppLogs ($appId: Int!, $envId: Int!, $logType: AppEnvironmentLogType!, $limit: Int!, $after: String) { + app(id: $appId) { + id + environments(id: $envId) { + id + logs(type: $logType, limit: $limit, after: $after) { + nodes { + timestamp + message + } + nextCursor + pollingDelaySeconds + } + } + } +} +` + +func GetAppLogs( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + logType AppEnvironmentLogType, + limit int64, + after *string, +) (data_ *GetAppLogsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAppLogs", + Query: GetAppLogs_Operation, + Variables: &__GetAppLogsInput{ + AppId: appId, + EnvId: envId, + LogType: logType, + Limit: limit, + After: after, + }, + } + + data_ = &GetAppLogsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetAppSlowlogs. +const GetAppSlowlogs_Operation = ` +query GetAppSlowlogs ($appId: Int!, $envId: Int!, $limit: Int!, $after: String) { + app(id: $appId) { + id + environments(id: $envId) { + id + slowlogs(limit: $limit, after: $after) { + nodes { + timestamp + rowsSent + rowsExamined + queryTime + requestUri + query + } + nextCursor + pollingDelaySeconds + } + } + } +} +` + +func GetAppSlowlogs( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + limit int64, + after *string, +) (data_ *GetAppSlowlogsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAppSlowlogs", + Query: GetAppSlowlogs_Operation, + Variables: &__GetAppSlowlogsInput{ + AppId: appId, + EnvId: envId, + Limit: limit, + After: after, + }, + } + + data_ = &GetAppSlowlogsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetEnvironmentVariables. +const GetEnvironmentVariables_Operation = ` +query GetEnvironmentVariables ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + } + } + } + } +} +` + +func GetEnvironmentVariables( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *GetEnvironmentVariablesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetEnvironmentVariables", + Query: GetEnvironmentVariables_Operation, + Variables: &__GetEnvironmentVariablesInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &GetEnvironmentVariablesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetEnvironmentVariablesWithValues. +const GetEnvironmentVariablesWithValues_Operation = ` +query GetEnvironmentVariablesWithValues ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + value + } + } + } + } +} +` + +func GetEnvironmentVariablesWithValues( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *GetEnvironmentVariablesWithValuesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetEnvironmentVariablesWithValues", + Query: GetEnvironmentVariablesWithValues_Operation, + Variables: &__GetEnvironmentVariablesWithValuesInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &GetEnvironmentVariablesWithValuesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ImportSQLEnvInfo. +const ImportSQLEnvInfo_Operation = ` +query ImportSQLEnvInfo ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + launched + isK8sResident + primaryDomain { + name + } + importStatus { + dbOperationInProgress + importInProgress + } + wpSitesSDS { + nodes { + homeUrl + id + } + } + } + } +} +` + +func ImportSQLEnvInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *ImportSQLEnvInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ImportSQLEnvInfo", + Query: ImportSQLEnvInfo_Operation, + Variables: &__ImportSQLEnvInfoInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &ImportSQLEnvInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ImportSQLProgress. +const ImportSQLProgress_Operation = ` +query ImportSQLProgress ($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + isK8sResident + launched + jobs(types: ["sql_import"]) { + __typename + id + type + completedAt + createdAt + progress { + status + steps { + id + name + status + } + } + } + importStatus { + dbOperationInProgress + importInProgress + progress { + started_at + steps { + name + started_at + finished_at + result + output + } + finished_at + } + } + } + } +} +` + +func ImportSQLProgress( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *ImportSQLProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ImportSQLProgress", + Query: ImportSQLProgress_Operation, + Variables: &__ImportSQLProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &ImportSQLProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by Me. +const Me_Operation = ` +query Me { + me { + id + displayName + isVIP + organizationRoles { + nodes { + organizationId + roleId + } + } + } +} +` + +func Me( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *MeResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "Me", + Query: Me_Operation, + } + + data_ = &MeResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MediaImportConfig. +const MediaImportConfig_Operation = ` +query MediaImportConfig { + mediaImportConfig { + fileNameCharCount + fileSizeLimitInBytes + allowedFileTypes + } +} +` + +func MediaImportConfig( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *MediaImportConfigResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MediaImportConfig", + Query: MediaImportConfig_Operation, + } + + data_ = &MediaImportConfigResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MediaImportProgress. +const MediaImportProgress_Operation = ` +query MediaImportProgress ($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + name + type + repo + mediaImportStatus { + importId + siteId + status + filesTotal + filesProcessed + failureDetails { + previousStatus + globalErrors + fileErrorsUrl + } + } + } + } +} +` + +func MediaImportProgress( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *MediaImportProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MediaImportProgress", + Query: MediaImportProgress_Operation, + Variables: &__MediaImportProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &MediaImportProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by PhpMyAdminStatus. +const PhpMyAdminStatus_Operation = ` +query PhpMyAdminStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + phpMyAdminStatus { + status + } + } + } +} +` + +func PhpMyAdminStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *PhpMyAdminStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "PhpMyAdminStatus", + Query: PhpMyAdminStatus_Operation, + Variables: &__PhpMyAdminStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &PhpMyAdminStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by PurgePageCache. +const PurgePageCache_Operation = ` +mutation PurgePageCache ($input: PurgePageCacheInput!) { + purgePageCache(input: $input) { + success + urls + } +} +` + +func PurgePageCache( + ctx_ context.Context, + client_ graphql.Client, + input *PurgePageCacheInput, +) (data_ *PurgePageCacheResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "PurgePageCache", + Query: PurgePageCache_Operation, + Variables: &__PurgePageCacheInput{ + Input: input, + }, + } + + data_ = &PurgePageCacheResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ResolveAppByID. +const ResolveAppByID_Operation = ` +query ResolveAppByID ($id: Int!) { + app(id: $id) { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } +} +` + +func ResolveAppByID( + ctx_ context.Context, + client_ graphql.Client, + id int64, +) (data_ *ResolveAppByIDResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ResolveAppByID", + Query: ResolveAppByID_Operation, + Variables: &__ResolveAppByIDInput{ + Id: id, + }, + } + + data_ = &ResolveAppByIDResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ResolveAppByName. +const ResolveAppByName_Operation = ` +query ResolveAppByName ($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } + } +} +` + +func ResolveAppByName( + ctx_ context.Context, + client_ graphql.Client, + name string, +) (data_ *ResolveAppByNameResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ResolveAppByName", + Query: ResolveAppByName_Operation, + Variables: &__ResolveAppByNameInput{ + Name: name, + }, + } + + data_ = &ResolveAppByNameResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SoftwareSettings. +const SoftwareSettings_Operation = ` +query SoftwareSettings ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + softwareSettings { + wordpress { + ... SoftwareNode + } + php { + ... SoftwareNode + } + muplugins { + ... SoftwareNode + } + nodejs { + ... SoftwareNode + } + } + } + } +} +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { + version + default + deprecated + unstable + compatible + latestRelease + private + } + options { + version + default + deprecated + unstable + compatible + latestRelease + private + } +} +` + +func SoftwareSettings( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SoftwareSettingsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SoftwareSettings", + Query: SoftwareSettings_Operation, + Variables: &__SoftwareSettingsInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SoftwareSettingsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SoftwareUpdateJob. +const SoftwareUpdateJob_Operation = ` +query SoftwareUpdateJob ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + jobs(types: ["upgrade_php","upgrade_wordpress","upgrade_muplugins","upgrade_nodejs"]) { + __typename + type + completedAt + createdAt + inProgressLock + progress { + status + steps { + step + name + status + } + } + } + } + } +} +` + +func SoftwareUpdateJob( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SoftwareUpdateJobResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SoftwareUpdateJob", + Query: SoftwareUpdateJob_Operation, + Variables: &__SoftwareUpdateJobInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SoftwareUpdateJobResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartCustomDeploy. +const StartCustomDeploy_Operation = ` +mutation StartCustomDeploy ($input: AppEnvironmentCustomDeployInput) { + startCustomDeploy(input: $input) { + success + message + } +} +` + +func StartCustomDeploy( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentCustomDeployInput, +) (data_ *StartCustomDeployResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartCustomDeploy", + Query: StartCustomDeploy_Operation, + Variables: &__StartCustomDeployInput{ + Input: input, + }, + } + + data_ = &StartCustomDeployResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartImport. +const StartImport_Operation = ` +mutation StartImport ($input: AppEnvironmentImportInput) { + startImport(input: $input) { + app { + id + name + } + message + success + } +} +` + +// The startImport server resolver calls input.searchReplace.filter(...) and +// expects urlHeaders to be present, so empty arrays must be sent as [] rather +// than omitted. Disable genqlient's default omitempty on these list fields to +// match the Node CLI (which always sends searchReplace: []). The $input variable +// is on its own line so the for-directives attach to the operation, not $input. +// +// `--search-replace="a"` (no comma) leaves arr[1] undefined in Node +// (vip-import-sql.js:821-827), and JSON.stringify drops undefined properties, +// so the pair goes over the wire as {from:"a"} with NO `to` key. Sending +// to:"" instead means "replace every occurrence of a with nothing" — silent +// data destruction. omitempty lets a nil *string reproduce Node's omission; +// a non-nil pointer to "" (from a trailing comma, "a,") still serializes. +func StartImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentImportInput, +) (data_ *StartImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartImport", + Query: StartImport_Operation, + Variables: &__StartImportInput{ + Input: input, + }, + } + + data_ = &StartImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartLiveBackupCopy. +const StartLiveBackupCopy_Operation = ` +mutation StartLiveBackupCopy ($input: LiveBackupCopyConfigInput!) { + startLiveBackupCopy(input: $input) { + message + copyId + } +} +` + +func StartLiveBackupCopy( + ctx_ context.Context, + client_ graphql.Client, + input *LiveBackupCopyConfigInput, +) (data_ *StartLiveBackupCopyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartLiveBackupCopy", + Query: StartLiveBackupCopy_Operation, + Variables: &__StartLiveBackupCopyInput{ + Input: input, + }, + } + + data_ = &StartLiveBackupCopyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartMediaImport. +const StartMediaImport_Operation = ` +mutation StartMediaImport ($input: AppEnvironmentStartMediaImportInput) { + startMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatus { + importId + siteId + status + } + } +} +` + +func StartMediaImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentStartMediaImportInput, +) (data_ *StartMediaImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartMediaImport", + Query: StartMediaImport_Operation, + Variables: &__StartMediaImportInput{ + Input: input, + }, + } + + data_ = &StartMediaImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by SyncEnvironment. +const SyncEnvironment_Operation = ` +mutation SyncEnvironment ($input: AppEnvironmentSyncInput!) { + syncEnvironment(input: $input) { + environment { + id + } + } +} +` + +func SyncEnvironment( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentSyncInput, +) (data_ *SyncEnvironmentResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncEnvironment", + Query: SyncEnvironment_Operation, + Variables: &__SyncEnvironmentInput{ + Input: input, + }, + } + + data_ = &SyncEnvironmentResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SyncPreview. +const SyncPreview_Operation = ` +query SyncPreview ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncPreview { + canSync + errors { + message + } + backup { + createdAt + } + replacements { + from + to + } + } + } + } +} +` + +// The pre-flight Node runs before the sync mutation. Node folds these +// fields into vip-sync.js's appQuery; vip-next resolves app/env through a +// shared query, so the preview is fetched separately by the confirmation +// payload (src/lib/cli/command.js:913-933). +func SyncPreview( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SyncPreviewResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncPreview", + Query: SyncPreview_Operation, + Variables: &__SyncPreviewInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SyncPreviewResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SyncProgress. +const SyncProgress_Operation = ` +query SyncProgress ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncProgress { + status + sync + steps { + name + status + step + } + } + } + } +} +` + +func SyncProgress( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SyncProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncProgress", + Query: SyncProgress_Operation, + Variables: &__SyncProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SyncProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by TriggerDatabaseBackup. +const TriggerDatabaseBackup_Operation = ` +mutation TriggerDatabaseBackup ($input: AppEnvironmentTriggerDBBackupInput) { + triggerDatabaseBackup(input: $input) { + success + } +} +` + +func TriggerDatabaseBackup( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentTriggerDBBackupInput, +) (data_ *TriggerDatabaseBackupResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TriggerDatabaseBackup", + Query: TriggerDatabaseBackup_Operation, + Variables: &__TriggerDatabaseBackupInput{ + Input: input, + }, + } + + data_ = &TriggerDatabaseBackupResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by TriggerWPCLICommand. +const TriggerWPCLICommand_Operation = ` +mutation TriggerWPCLICommand ($input: AppEnvironmentTriggerWPCLICommandInput) { + triggerWPCLICommandOnAppEnvironment(input: $input) { + inputToken + command { + guid + } + sshAuthentication { + host + port + username + privateKey + passphrase + } + } +} +` + +func TriggerWPCLICommand( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentTriggerWPCLICommandInput, +) (data_ *TriggerWPCLICommandResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TriggerWPCLICommand", + Query: TriggerWPCLICommand_Operation, + Variables: &__TriggerWPCLICommandInput{ + Input: input, + }, + } + + data_ = &TriggerWPCLICommandResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateDefensiveModeConfig. +const UpdateDefensiveModeConfig_Operation = ` +mutation UpdateDefensiveModeConfig ($input: AppEnvironmentDefensiveModeConfigInput!) { + updateDefensiveModeConfig(input: $input) { + success + message + } +} +` + +func UpdateDefensiveModeConfig( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentDefensiveModeConfigInput, +) (data_ *UpdateDefensiveModeConfigResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateDefensiveModeConfig", + Query: UpdateDefensiveModeConfig_Operation, + Variables: &__UpdateDefensiveModeConfigInput{ + Input: input, + }, + } + + data_ = &UpdateDefensiveModeConfigResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateDefensiveModeStatus. +const UpdateDefensiveModeStatus_Operation = ` +mutation UpdateDefensiveModeStatus ($input: AppEnvironmentDefensiveModeUpdateStatusInput!) { + updateDefensiveModeStatus(input: $input) { + success + message + } +} +` + +func UpdateDefensiveModeStatus( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentDefensiveModeUpdateStatusInput, +) (data_ *UpdateDefensiveModeStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateDefensiveModeStatus", + Query: UpdateDefensiveModeStatus_Operation, + Variables: &__UpdateDefensiveModeStatusInput{ + Input: input, + }, + } + + data_ = &UpdateDefensiveModeStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateSoftwareSettings. +const UpdateSoftwareSettings_Operation = ` +mutation UpdateSoftwareSettings ($appId: Int!, $envId: Int!, $component: String!, $version: String!) { + updateSoftwareSettings(input: {appId:$appId,environmentId:$envId,softwareName:$component,softwareVersion:$version}) { + wordpress { + ... SoftwareNode + } + php { + ... SoftwareNode + } + muplugins { + ... SoftwareNode + } + nodejs { + ... SoftwareNode + } + } +} +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { + version + default + deprecated + unstable + compatible + latestRelease + private + } + options { + version + default + deprecated + unstable + compatible + latestRelease + private + } +} +` + +func UpdateSoftwareSettings( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + component string, + version string, +) (data_ *UpdateSoftwareSettingsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateSoftwareSettings", + Query: UpdateSoftwareSettings_Operation, + Variables: &__UpdateSoftwareSettingsInput{ + AppId: appId, + EnvId: envId, + Component: component, + Version: version, + }, + } + + data_ = &UpdateSoftwareSettingsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by ValidateCustomDeployAccess. +const ValidateCustomDeployAccess_Operation = ` +mutation ValidateCustomDeployAccess ($input: ValidateCustomDeployAccessInput!) { + validateCustomDeployAccess(input: $input) { + success + appId + envId + envType + envUniqueLabel + primaryDomainName + launched + } +} +` + +func ValidateCustomDeployAccess( + ctx_ context.Context, + client_ graphql.Client, + input *ValidateCustomDeployAccessInput, +) (data_ *ValidateCustomDeployAccessResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ValidateCustomDeployAccess", + Query: ValidateCustomDeployAccess_Operation, + Variables: &__ValidateCustomDeployAccessInput{ + Input: input, + }, + } + + data_ = &ValidateCustomDeployAccessResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by WPEnvInfo. +const WPEnvInfo_Operation = ` +query WPEnvInfo ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + wpcliStrategy + primaryDomain { + name + } + } + } +} +` + +func WPEnvInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *WPEnvInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "WPEnvInfo", + Query: WPEnvInfo_Operation, + Variables: &__WPEnvInfoInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &WPEnvInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} diff --git a/internal/gql/genqlient.yaml b/internal/gql/genqlient.yaml new file mode 100644 index 000000000..e0ee0d22d --- /dev/null +++ b/internal/gql/genqlient.yaml @@ -0,0 +1,20 @@ +schema: schema.gql +operations: + - operations/*.graphql +generated: generated.go +package: gql +use_struct_references: true +optional: pointer +bindings: + Int: + type: int64 + ID: + type: string + BigInt: + type: int64 + JSON: + type: encoding/json.RawMessage + # Free-form {ext: type-label} map returned by mediaImportConfig + # (media-import/config.ts) — same raw-JSON treatment as JSON. + MediaImportAllowedFileTypes: + type: encoding/json.RawMessage diff --git a/internal/gql/import_sql_marshal_test.go b/internal/gql/import_sql_marshal_test.go new file mode 100644 index 000000000..f1e577f72 --- /dev/null +++ b/internal/gql/import_sql_marshal_test.go @@ -0,0 +1,69 @@ +package gql + +import ( + "encoding/json" + "strings" + "testing" +) + +// The startImport server resolver calls input.searchReplace.filter(...). If the +// field is omitted from the request (undefined), it crashes with +// "Cannot read properties of undefined (reading 'filter')". Node always sends +// searchReplace: [], so an empty SearchReplace MUST serialize as [] rather than +// be dropped by omitempty. Same applies to urlHeaders on the URL path. +func TestStartImportInputAlwaysSendsSearchReplace(t *testing.T) { + in := &AppEnvironmentImportInput{ + SearchReplace: []*AppEnvironmentImportSearchReplace{}, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"searchReplace":[]`) { + t.Fatalf("empty searchReplace dropped (omitempty) — server will crash on .filter(); got: %s", b) + } +} + +// `--search-replace="a"` (no comma) must reach the server as {from:"a"} with +// no `to` key — Node's JSON.stringify drops the undefined arr[1]. A nil *To +// therefore has to be OMITTED, not emitted as null: "to":null and a missing +// `to` are different inputs to the resolver, and "to":"" is worse still +// (delete every occurrence of `from`). Guards the +// @genqlient(for: "AppEnvironmentImportSearchReplace.to", omitempty: true) +// directive in operations/import_sql.graphql. +func TestSearchReplaceOmitsNilTo(t *testing.T) { + from := "a" + b, err := json.Marshal(&AppEnvironmentImportSearchReplace{From: &from}) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"from":"a"}` { + t.Fatalf("got %s, want {\"from\":\"a\"} — a nil To must be omitted, not null", b) + } +} + +// A trailing comma ("a,") is a real second segment in JS, so an explicitly +// empty `to` must still be transmitted. +func TestSearchReplaceKeepsExplicitEmptyTo(t *testing.T) { + from, to := "a", "" + b, err := json.Marshal(&AppEnvironmentImportSearchReplace{From: &from, To: &to}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"to":""`) { + t.Fatalf("got %s, want an explicit \"to\":\"\"", b) + } +} + +func TestStartImportInputAlwaysSendsUrlHeaders(t *testing.T) { + in := &AppEnvironmentImportInput{ + UrlHeaders: []*RequestHeader{}, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"urlHeaders":[]`) { + t.Fatalf("empty urlHeaders dropped (omitempty); got: %s", b) + } +} diff --git a/internal/gql/operation.go b/internal/gql/operation.go new file mode 100644 index 000000000..b84c22786 --- /dev/null +++ b/internal/gql/operation.go @@ -0,0 +1,67 @@ +package gql + +import ( + "fmt" + + json "encoding/json/v2" + + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/parser" +) + +// Operation describes the relevant shape of a GraphQL request as inspected +// by the rechallenge middleware. +type Operation struct { + OperationName string + IsMutation bool + PrimaryFieldName string // first FIELD in the operation's selection set +} + +// ParseOperationFromBody decodes the JSON request body, parses the contained +// "query" string, and reports whether it's a mutation along with its primary +// field name (the rechallenge "scope"). +func ParseOperationFromBody(body []byte) (*Operation, error) { + var raw struct { + OperationName string `json:"operationName"` + Query string `json:"query"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("decode body: %w", err) + } + if raw.Query == "" { + return nil, fmt.Errorf("body has no query field") + } + doc, err := parser.ParseQuery(&ast.Source{Input: raw.Query}) + if err != nil { + return nil, fmt.Errorf("parse query: %w", err) + } + op := selectOperation(doc, raw.OperationName) + if op == nil { + return nil, fmt.Errorf("no operations in query") + } + out := &Operation{ + OperationName: op.Name, + IsMutation: op.Operation == ast.Mutation, + } + for _, sel := range op.SelectionSet { + if f, ok := sel.(*ast.Field); ok { + out.PrimaryFieldName = f.Name + break + } + } + return out, nil +} + +func selectOperation(doc *ast.QueryDocument, opName string) *ast.OperationDefinition { + if opName != "" { + for _, op := range doc.Operations { + if op.Name == opName { + return op + } + } + } + if len(doc.Operations) > 0 { + return doc.Operations[0] + } + return nil +} diff --git a/internal/gql/operation_test.go b/internal/gql/operation_test.go new file mode 100644 index 000000000..7d75df022 --- /dev/null +++ b/internal/gql/operation_test.go @@ -0,0 +1,60 @@ +package gql + +import "testing" + +func TestParseOperationMutation(t *testing.T) { + body := `{"operationName":"UpdateThing","query":"mutation UpdateThing($x:Int!){updateDefensiveModeStatus(input:{id:$x}){success}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if !op.IsMutation { + t.Error("IsMutation must be true") + } + if op.PrimaryFieldName != "updateDefensiveModeStatus" { + t.Errorf("PrimaryFieldName = %q, want updateDefensiveModeStatus", op.PrimaryFieldName) + } + if op.OperationName != "UpdateThing" { + t.Errorf("OperationName = %q", op.OperationName) + } +} + +func TestParseOperationQuery(t *testing.T) { + body := `{"operationName":"Me","query":"query Me{me{id displayName}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if op.IsMutation { + t.Error("IsMutation must be false for a query") + } +} + +func TestParseOperationAnonymousMutation(t *testing.T) { + body := `{"query":"mutation{updateDefensiveModeStatus(input:{}){success}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if !op.IsMutation { + t.Error("IsMutation must be true") + } + if op.PrimaryFieldName != "updateDefensiveModeStatus" { + t.Errorf("PrimaryFieldName = %q", op.PrimaryFieldName) + } +} + +func TestParseOperationMalformedBody(t *testing.T) { + _, err := ParseOperationFromBody([]byte("not json")) + if err == nil { + t.Error("expected error on malformed body") + } +} + +func TestParseOperationMalformedQuery(t *testing.T) { + body := `{"query":"mutation { broken"}` + _, err := ParseOperationFromBody([]byte(body)) + if err == nil { + t.Error("expected error on malformed GraphQL") + } +} diff --git a/internal/gql/operations/app_get.graphql b/internal/gql/operations/app_get.graphql new file mode 100644 index 000000000..1260cf8e0 --- /dev/null +++ b/internal/gql/operations/app_get.graphql @@ -0,0 +1,43 @@ +query AppGetByName($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } + } +} + +query AppGetByID($id: Int!) { + app(id: $id) { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } +} diff --git a/internal/gql/operations/app_list.graphql b/internal/gql/operations/app_list.graphql new file mode 100644 index 000000000..3c50f8472 --- /dev/null +++ b/internal/gql/operations/app_list.graphql @@ -0,0 +1,9 @@ +query AppList($first: Int, $after: String) { + apps(first: $first, after: $after) { + total + nextCursor + edges { + ...AppBasic + } + } +} diff --git a/internal/gql/operations/app_resolve.graphql b/internal/gql/operations/app_resolve.graphql new file mode 100644 index 000000000..d78909e7a --- /dev/null +++ b/internal/gql/operations/app_resolve.graphql @@ -0,0 +1,37 @@ +query ResolveAppByName($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } + } +} + +query ResolveAppByID($id: Int!) { + app(id: $id) { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } +} diff --git a/internal/gql/operations/backup_export_deploy.graphql b/internal/gql/operations/backup_export_deploy.graphql new file mode 100644 index 000000000..039d02958 --- /dev/null +++ b/internal/gql/operations/backup_export_deploy.graphql @@ -0,0 +1,123 @@ +# Backup / export / deploy operations. Node sources: +# TriggerDatabaseBackup / AppBackupJobStatus — src/commands/backup-db.ts:20,28 +# AppBackupAndJobStatus — src/commands/export-sql.ts:36 +# GenerateDBBackupCopyUrl / BackupDBCopy — src/commands/export-sql.ts:77,87 +# StartLiveBackupCopy / download URL — src/lib/live-backup-copy.ts:85,95 +# StartCustomDeploy — src/bin/vip-app-deploy.ts:29 +# ValidateCustomDeployAccess — src/lib/custom-deploy/custom-deploy.ts:36 + +mutation TriggerDatabaseBackup($input: AppEnvironmentTriggerDBBackupInput) { + triggerDatabaseBackup(input: $input) { + success + } +} + +query AppBackupJobStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + jobs(jobTypes: [db_backup]) { + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + } + } + } + } +} + +query AppBackupAndJobStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + backupsSqlDumpTool + latestBackup { + id + type + size + filename + sqlDumpTool + createdAt + } + jobs(jobTypes: [db_backup_copy]) { + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + steps { + id + name + step + status + } + } + } + } + } +} + +mutation GenerateDBBackupCopyUrl($input: AppEnvironmentGenerateDBBackupCopyUrlInput) { + generateDBBackupCopyUrl(input: $input) { + url + success + } +} + +mutation BackupDBCopy($input: AppEnvironmentStartDBBackupCopyInput) { + startDBBackupCopy(input: $input) { + message + success + } +} + +mutation StartLiveBackupCopy($input: LiveBackupCopyConfigInput!) { + startLiveBackupCopy(input: $input) { + message + copyId + } +} + +mutation GenerateLiveBackupCopyDownloadURL($input: AppEnvironmentLiveBackupCopyDownloadURLInput!) { + generateLiveBackupCopyDownloadURL(input: $input) { + success + url + processing + size + } +} + +mutation StartCustomDeploy($input: AppEnvironmentCustomDeployInput) { + startCustomDeploy(input: $input) { + success + message + } +} + +mutation ValidateCustomDeployAccess($input: ValidateCustomDeployAccessInput!) { + validateCustomDeployAccess(input: $input) { + success + appId + envId + envType + envUniqueLabel + primaryDomainName + launched + } +} diff --git a/internal/gql/operations/cachepurge.graphql b/internal/gql/operations/cachepurge.graphql new file mode 100644 index 000000000..a72f8a15e --- /dev/null +++ b/internal/gql/operations/cachepurge.graphql @@ -0,0 +1,6 @@ +mutation PurgePageCache($input: PurgePageCacheInput!) { + purgePageCache(input: $input) { + success + urls + } +} diff --git a/internal/gql/operations/defensive_mode.graphql b/internal/gql/operations/defensive_mode.graphql new file mode 100644 index 000000000..932840298 --- /dev/null +++ b/internal/gql/operations/defensive_mode.graphql @@ -0,0 +1,13 @@ +mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput!) { + updateDefensiveModeStatus(input: $input) { + success + message + } +} + +mutation UpdateDefensiveModeConfig($input: AppEnvironmentDefensiveModeConfigInput!) { + updateDefensiveModeConfig(input: $input) { + success + message + } +} diff --git a/internal/gql/operations/dev_env_create.graphql b/internal/gql/operations/dev_env_create.graphql new file mode 100644 index 000000000..1d618d85e --- /dev/null +++ b/internal/gql/operations/dev_env_create.graphql @@ -0,0 +1,38 @@ +# dev-env create @app.env pre-population. Node source: +# getApplicationInformation — src/lib/dev-environment/dev-environment-core.ts:735 +# getOptionsFromAppInfo — src/lib/dev-environment/dev-environment-cli.ts:257 +# Fetches all environments (no useful server-side filter; the env is picked +# client-side by type) with the fields that seed the wizard defaults. +query DevEnvAppInfo($appId: Int!) { + app(id: $appId) { + id + name + environments { + id + appId + name + type + isMultisite + primaryDomain { + name + } + environmentVariables { + nodes { + name + } + } + softwareSettings { + php { + current { + version + } + } + wordpress { + current { + version + } + } + } + } + } +} diff --git a/internal/gql/operations/dev_env_sync.graphql b/internal/gql/operations/dev_env_sync.graphql new file mode 100644 index 000000000..14caadc61 --- /dev/null +++ b/internal/gql/operations/dev_env_sync.graphql @@ -0,0 +1,15 @@ +query DevEnvSyncSites($appId: Int!, $environmentId: Int!, $after: String, $first: Int!) { + app(id: $appId) { + environments(id: $environmentId) { + wpSitesSDS(after: $after, first: $first) { + total + nextCursor + nodes { + blogId + homeUrl + siteUrl + } + } + } + } +} diff --git a/internal/gql/operations/envvar.graphql b/internal/gql/operations/envvar.graphql new file mode 100644 index 000000000..1d87ebb96 --- /dev/null +++ b/internal/gql/operations/envvar.graphql @@ -0,0 +1,52 @@ +query GetEnvironmentVariables($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + } + } + } + } +} + +query GetEnvironmentVariablesWithValues($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + value + } + } + } + } +} + +mutation AddEnvironmentVariable($input: EnvironmentVariableInput!) { + addEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} + +mutation DeleteEnvironmentVariable($input: EnvironmentVariableInput!) { + deleteEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} diff --git a/internal/gql/operations/fragments.graphql b/internal/gql/operations/fragments.graphql new file mode 100644 index 000000000..64d37e952 --- /dev/null +++ b/internal/gql/operations/fragments.graphql @@ -0,0 +1,5 @@ +fragment AppBasic on App { + id + name + repo +} diff --git a/internal/gql/operations/import_media.graphql b/internal/gql/operations/import_media.graphql new file mode 100644 index 000000000..ad934e0fc --- /dev/null +++ b/internal/gql/operations/import_media.graphql @@ -0,0 +1,61 @@ +# Media-import operations. Node sources: +# StartMediaImport — src/bin/vip-import-media.js:37 +# AbortMediaImport — src/bin/vip-import-media-abort.js:33 +# progress query — src/lib/media-import/status.ts:28 +# MediaImportConfig — src/lib/media-import/config.ts:8 + +mutation StartMediaImport($input: AppEnvironmentStartMediaImportInput) { + startMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatus { + importId + siteId + status + } + } +} + +mutation AbortMediaImport($input: AppEnvironmentAbortMediaImportInput) { + abortMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatusChange { + importId + siteId + statusFrom + statusTo + } + } +} + +query MediaImportProgress($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + name + type + repo + mediaImportStatus { + importId + siteId + status + filesTotal + filesProcessed + failureDetails { + previousStatus + globalErrors + fileErrorsUrl + } + } + } + } +} + +query MediaImportConfig { + mediaImportConfig { + fileNameCharCount + fileSizeLimitInBytes + allowedFileTypes + } +} diff --git a/internal/gql/operations/import_sql.graphql b/internal/gql/operations/import_sql.graphql new file mode 100644 index 000000000..f158cff99 --- /dev/null +++ b/internal/gql/operations/import_sql.graphql @@ -0,0 +1,135 @@ +# Import-sql operations. Node sources: +# appQuery — src/bin/vip-import-sql.js:41 +# StartImport — src/bin/vip-import-sql.js:69 +# AppMultiSiteCheck — src/lib/validations/is-multi-site.ts:27 +# AppMappedDomains — src/lib/validations/is-multisite-domain-mapped.ts:82 +# App (import status) — src/lib/site-import/status.ts:27 + +query ImportSQLEnvInfo($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + launched + isK8sResident + primaryDomain { + name + } + importStatus { + dbOperationInProgress + importInProgress + } + wpSitesSDS { + nodes { + homeUrl + id + } + } + } + } +} + +# The startImport server resolver calls input.searchReplace.filter(...) and +# expects urlHeaders to be present, so empty arrays must be sent as [] rather +# than omitted. Disable genqlient's default omitempty on these list fields to +# match the Node CLI (which always sends searchReplace: []). The $input variable +# is on its own line so the for-directives attach to the operation, not $input. +# @genqlient(for: "AppEnvironmentImportInput.searchReplace", omitempty: false) +# @genqlient(for: "AppEnvironmentImportInput.urlHeaders", omitempty: false) +# +# `--search-replace="a"` (no comma) leaves arr[1] undefined in Node +# (vip-import-sql.js:821-827), and JSON.stringify drops undefined properties, +# so the pair goes over the wire as {from:"a"} with NO `to` key. Sending +# to:"" instead means "replace every occurrence of a with nothing" — silent +# data destruction. omitempty lets a nil *string reproduce Node's omission; +# a non-nil pointer to "" (from a trailing comma, "a,") still serializes. +# @genqlient(for: "AppEnvironmentImportSearchReplace.to", omitempty: true) +mutation StartImport( + $input: AppEnvironmentImportInput +) { + startImport(input: $input) { + app { + id + name + } + message + success + } +} + +query AppMultiSiteCheck($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + repo + environments(id: $envId) { + id + appId + name + type + isMultisite + isSubdirectoryMultisite + } + } +} + +query AppMappedDomains($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + environments(id: $envId) { + uniqueLabel + isMultisite + domains { + nodes { + name + isPrimary + } + } + } + } +} + +query ImportSQLProgress($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + isK8sResident + launched + jobs(types: ["sql_import"]) { + id + type + completedAt + createdAt + progress { + status + steps { + id + name + status + } + } + } + importStatus { + dbOperationInProgress + importInProgress + progress { + started_at + steps { + name + started_at + finished_at + result + output + } + finished_at + } + } + } + } +} diff --git a/internal/gql/operations/logs.graphql b/internal/gql/operations/logs.graphql new file mode 100644 index 000000000..8d2aa64ab --- /dev/null +++ b/internal/gql/operations/logs.graphql @@ -0,0 +1,22 @@ +query GetAppLogs( + $appId: Int! + $envId: Int! + $logType: AppEnvironmentLogType! + $limit: Int! + $after: String +) { + app(id: $appId) { + id + environments(id: $envId) { + id + logs(type: $logType, limit: $limit, after: $after) { + nodes { + timestamp + message + } + nextCursor + pollingDelaySeconds + } + } + } +} diff --git a/internal/gql/operations/me.graphql b/internal/gql/operations/me.graphql new file mode 100644 index 000000000..dfe100ebc --- /dev/null +++ b/internal/gql/operations/me.graphql @@ -0,0 +1,13 @@ +query Me { + me { + id + displayName + isVIP + organizationRoles { + nodes { + organizationId + roleId + } + } + } +} diff --git a/internal/gql/operations/phpmyadmin.graphql b/internal/gql/operations/phpmyadmin.graphql new file mode 100644 index 000000000..b7f5921be --- /dev/null +++ b/internal/gql/operations/phpmyadmin.graphql @@ -0,0 +1,21 @@ +mutation EnablePhpMyAdmin($input: EnablePhpMyAdminInput!) { + enablePHPMyAdmin(input: $input) { + success + } +} + +query PhpMyAdminStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + phpMyAdminStatus { + status + } + } + } +} + +mutation GeneratePhpMyAdminAccess($input: GeneratePhpMyAdminAccessInput!) { + generatePHPMyAdminAccess(input: $input) { + url + } +} diff --git a/internal/gql/operations/slowlogs.graphql b/internal/gql/operations/slowlogs.graphql new file mode 100644 index 000000000..a09f36c31 --- /dev/null +++ b/internal/gql/operations/slowlogs.graphql @@ -0,0 +1,25 @@ +query GetAppSlowlogs( + $appId: Int! + $envId: Int! + $limit: Int! + $after: String +) { + app(id: $appId) { + id + environments(id: $envId) { + id + slowlogs(limit: $limit, after: $after) { + nodes { + timestamp + rowsSent + rowsExamined + queryTime + requestUri + query + } + nextCursor + pollingDelaySeconds + } + } + } +} diff --git a/internal/gql/operations/software.graphql b/internal/gql/operations/software.graphql new file mode 100644 index 000000000..b8eadcaef --- /dev/null +++ b/internal/gql/operations/software.graphql @@ -0,0 +1,53 @@ +# vip config software operations. Node source: src/lib/config/software.ts +# (appQuery/appQueryFragments, updateSoftwareMutation, updateJobQuery). + +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { version default deprecated unstable compatible latestRelease private } + options { version default deprecated unstable compatible latestRelease private } +} + +query SoftwareSettings($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + softwareSettings { + wordpress { ...SoftwareNode } + php { ...SoftwareNode } + muplugins { ...SoftwareNode } + nodejs { ...SoftwareNode } + } + } + } +} + +mutation UpdateSoftwareSettings($appId: Int!, $envId: Int!, $component: String!, $version: String!) { + updateSoftwareSettings(input: {appId: $appId, environmentId: $envId, softwareName: $component, softwareVersion: $version}) { + wordpress { ...SoftwareNode } + php { ...SoftwareNode } + muplugins { ...SoftwareNode } + nodejs { ...SoftwareNode } + } +} + +query SoftwareUpdateJob($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + jobs(types: ["upgrade_php", "upgrade_wordpress", "upgrade_muplugins", "upgrade_nodejs"]) { + type + completedAt + createdAt + inProgressLock + progress { status steps { step name status } } + } + } + } +} diff --git a/internal/gql/operations/sync.graphql b/internal/gql/operations/sync.graphql new file mode 100644 index 000000000..b6eaa6be5 --- /dev/null +++ b/internal/gql/operations/sync.graphql @@ -0,0 +1,51 @@ +mutation SyncEnvironment($input: AppEnvironmentSyncInput!) { + syncEnvironment(input: $input) { + environment { + id + } + } +} + +# The pre-flight Node runs before the sync mutation. Node folds these +# fields into vip-sync.js's appQuery; vip-next resolves app/env through a +# shared query, so the preview is fetched separately by the confirmation +# payload (src/lib/cli/command.js:913-933). +query SyncPreview($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncPreview { + canSync + errors { + message + } + backup { + createdAt + } + replacements { + from + to + } + } + } + } +} + +query SyncProgress($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncProgress { + status + sync + steps { + name + status + step + } + } + } + } +} diff --git a/internal/gql/operations/wp.graphql b/internal/gql/operations/wp.graphql new file mode 100644 index 000000000..bf9c0b41d --- /dev/null +++ b/internal/gql/operations/wp.graphql @@ -0,0 +1,37 @@ +# vip wp operations. Node sources: +# TriggerWPCLICommand — src/bin/vip-wp.js:127 / src/commands/wp-ssh.ts:41 +# WPEnvInfo (wpcliStrategy + primaryDomain + typeId) — src/bin/vip-wp.js:26 + +mutation TriggerWPCLICommand($input: AppEnvironmentTriggerWPCLICommandInput) { + triggerWPCLICommandOnAppEnvironment(input: $input) { + inputToken + command { + guid + } + sshAuthentication { + host + port + username + privateKey + passphrase + } + } +} + +query WPEnvInfo($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + wpcliStrategy + primaryDomain { + name + } + } + } +} diff --git a/internal/gql/proxy_test.go b/internal/gql/proxy_test.go new file mode 100644 index 000000000..1b279e28d --- /dev/null +++ b/internal/gql/proxy_test.go @@ -0,0 +1,62 @@ +package gql + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestClientHonoursVIPProxy pins cutover item 2.14 on the path that carries the +// bearer token. gql.Client defaulted to http.DefaultClient, which ignores +// VIP_PROXY/SOCKS_PROXY entirely (a SOCKS user connected direct and never knew) +// and honours HTTPS_PROXY unconditionally (a user who declined system-proxy use +// had their token routed through a corporate proxy Node bypasses). +// +// The target is a live loopback server and the proxy a closed port. Neither +// net/http nor x/net's httpproxy will ever proxy a loopback host, so reaching +// the server proves the request went direct; Node's proxy-from-env has no such +// exemption, so the request must be attempted through the dead SOCKS port. +func TestClientHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + for _, k := range []string{ + "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY", "vip_proxy", + } { + t.Setenv(k, "") + } + t.Setenv("VIP_PROXY", "socks5://"+closedAddr(t)) + + c := NewClient(Config{APIHost: srv.URL, Token: "bearer-token-under-test"}) + req, err := http.NewRequest(http.MethodPost, srv.URL+"/graphql", + strings.NewReader(`{"operationName":"Me","query":"{me{id}}"}`)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + resp, err := c.Do(req) + if err == nil { + _ = resp.Body.Close() + t.Fatal("GraphQL request succeeded; VIP_PROXY was ignored and the bearer token went direct") + } +} + +func closedAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return addr +} diff --git a/internal/gql/rechallenge.go b/internal/gql/rechallenge.go new file mode 100644 index 000000000..0a0bd176c --- /dev/null +++ b/internal/gql/rechallenge.go @@ -0,0 +1,214 @@ +package gql + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/rechallenge" +) + +const defaultElevatedHeader = "x-elevated-token" + +// RechallengeConfig wires the middleware to its token cache + runner. +type RechallengeConfig struct { + TokenCache *rechallenge.TokenCache + Runner *rechallenge.Runner + // Context, if non-nil, supplies the context used for Parker calls. + // Defaults to context.Background(); production should pass the cobra + // command's ctx so SIGINT cancels the flow. + Context func() context.Context + // Interactive, when non-nil, replaces the default + // rechallenge.IsInteractiveContext(nil) fallback for the Runner's + // Interactive flag. main.go wires this from a closure over the cobra + // command tree so the middleware honors --non-interactive. + Interactive func() bool + // Wait, when non-nil, replaces rechallenge.ShouldWaitForRechallenge as the + // source of the "block on step-up even though nobody is here" opt-in. + // Injected by tests; production reads the environment. + Wait func() bool + // Stderr receives the step-up failure notice. Defaults to os.Stderr. + Stderr io.Writer +} + +// NewRechallengeMiddleware is the real middleware (M3) replacing the M2 no-op. +// +// On each outbound request: +// +// 1. Parse the GraphQL operation from the body. Non-mutations pass through. +// 2. Preflight: if TokenCache has a token for the mutation's primary field, +// attach it to the request as the elevated header. +// 3. Call next. Read response body. +// 4. If response contains errors[] with extensions.code == elevated-permission-required +// and a valid extensions.rechallenge, run the rechallenge flow. +// 5. On flow success: replay request ONCE with the elevated header. +// 6. On flow failure: report WHY to stderr, then return the ORIGINAL response +// (so error middleware sees it and the exit code is unchanged). +// +// Mirrors src/lib/rechallenge/link.ts, except for step 6's report: Node hides +// the step-up failure behind a `debug()` call, so unless DEBUG was already set +// the user is told only that they lack permission — which is neither the +// problem nor actionable. +func NewRechallengeMiddleware(cfg RechallengeConfig) Middleware { + return func(next Doer) Doer { + return &rechallengeDoer{next: next, cfg: cfg} + } +} + +type rechallengeDoer struct { + next Doer + cfg RechallengeConfig +} + +func (r *rechallengeDoer) ctx() context.Context { + if r.cfg.Context != nil { + if c := r.cfg.Context(); c != nil { + return c + } + } + return context.Background() +} + +func (r *rechallengeDoer) Do(req *http.Request) (*http.Response, error) { + // Snapshot body so we can replay it on retry. Mirrors the retry + // middleware's approach; we re-snapshot at this layer for our own retry. + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + + op, opErr := ParseOperationFromBody(body) + if opErr != nil || op == nil || !op.IsMutation || op.PrimaryFieldName == "" { + return r.next.Do(req) + } + scope := op.PrimaryFieldName + + // Preflight: cached elevated token wins. + if r.cfg.TokenCache != nil { + if tok, err := r.cfg.TokenCache.Get(scope); err == nil && tok != nil { + attachElevatedHeader(req, *tok) + } + } + + // First attempt. + resp, err := r.next.Do(req) + if err != nil || resp == nil { + return resp, err + } + + rb, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(rb)) + + ext := extractElevatedExtension(rb) + if ext == nil { + return resp, nil + } + + if r.cfg.Runner == nil { + return resp, nil + } + + interactive := rechallenge.IsInteractiveContext(nil) + if r.cfg.Interactive != nil { + interactive = r.cfg.Interactive() + } + wait := rechallenge.ShouldWaitForRechallenge() + if r.cfg.Wait != nil { + wait = r.cfg.Wait() + } + tok, runErr := r.cfg.Runner.Run(r.ctx(), rechallenge.RunInput{ + RequestedOperation: scope, + Extension: *ext, + Interactive: interactive, + Wait: wait, + }) + if runErr != nil || tok == nil { + r.reportStepUpFailure(scope, runErr) + // Surface the ORIGINAL error response upstream. + return resp, nil + } + + // Replay with the elevated header. Reuse the original body bytes. + retryReq := req.Clone(req.Context()) + retryReq.Body = io.NopCloser(bytes.NewReader(body)) + retryReq.ContentLength = int64(len(body)) + attachElevatedHeader(retryReq, *tok) + + return r.next.Do(retryReq) +} + +// reportStepUpFailure tells the user why step-up did not produce a token. +// +// Without it the only thing printed is the server's original +// elevated-permission error, which says the user lacks permission — true, but +// it is the symptom, not the cause. "Parker returned HTTP 503", "the approval +// was denied", "this is a non-interactive session" and "the session expired" +// all looked identical, and the reason for each was sitting in an error value +// that was discarded one line later. Same class of bug as 78d0a615. +// +// The text is server-controlled and lands in CI logs and the telemetry exit +// hook, so it goes through RedactSecrets with the bearer token as a known +// secret. rechallenge.Client redacts its own error bodies too; this is the +// second layer, covering error values that do not come from an HTTP body. +func (r *rechallengeDoer) reportStepUpFailure(scope string, runErr error) { + if runErr == nil { + return + } + w := r.cfg.Stderr + if w == nil { + w = os.Stderr + } + var token string + if r.cfg.Runner != nil && r.cfg.Runner.Client != nil { + token = r.cfg.Runner.Client.BearerToken + } + fmt.Fprintf(w, "Step-up verification failed for %s: %s\n", + scope, rechallenge.RedactSecrets(runErr.Error(), token)) +} + +func attachElevatedHeader(req *http.Request, tok rechallenge.ElevatedToken) { + name := tok.HeaderName + if name == "" { + name = defaultElevatedHeader + } + req.Header.Set(name, tok.Token) +} + +// extractElevatedExtension scans the response body for a GraphQL error whose +// extensions.code == elevated-permission-required and whose extensions.rechallenge +// is a complete Extension object. Returns nil if none found. +func extractElevatedExtension(body []byte) *rechallenge.Extension { + var doc struct { + Errors []struct { + Extensions struct { + Code string `json:"code"` + Rechallenge *rechallenge.Extension `json:"rechallenge"` + } `json:"extensions"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil + } + for _, e := range doc.Errors { + if e.Extensions.Code != rechallenge.ElevatedPermissionErrorCode { + continue + } + if e.Extensions.Rechallenge == nil || !e.Extensions.Rechallenge.IsValid() { + continue + } + return e.Extensions.Rechallenge + } + return nil +} diff --git a/internal/gql/rechallenge_test.go b/internal/gql/rechallenge_test.go new file mode 100644 index 000000000..2e87a70fa --- /dev/null +++ b/internal/gql/rechallenge_test.go @@ -0,0 +1,471 @@ +package gql + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/rechallenge" +) + +func newTestRechallengeCache() *rechallenge.TokenCache { + return &rechallenge.TokenCache{ + Keychain: &keychain.Keychain{Backend: &keychainMemBackend{}, Service: "vip-next-cli:elevated"}, + } +} + +type keychainMemBackend struct{ store map[string]string } + +func (m *keychainMemBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *keychainMemBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *keychainMemBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func TestRechallengePassThroughQuery(t *testing.T) { + calls := int32(0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: newTestRechallengeCache(), + })}, + }) + body := `{"operationName":"Me","query":"query Me{me{id}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (query — no rechallenge)", calls) + } +} + +func TestRechallengePreflightAttachesCachedToken(t *testing.T) { + var seenHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeader = r.Header.Get("x-elevated-token") + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer srv.Close() + cache := newTestRechallengeCache() + cache.Set("updateDefensiveModeStatus", rechallenge.ElevatedToken{ + Token: "cached-token", + ExpiresAt: time.Now().Add(time.Hour), + HeaderName: "x-elevated-token", + }) + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{TokenCache: cache})}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if seenHeader != "cached-token" { + t.Errorf("x-elevated-token header = %q, want cached-token", seenHeader) + } +} + +func TestRechallengeFullFlowOnElevatedError(t *testing.T) { + mutationHits := int32(0) + var headerAfterRetry string + + parker := http.NewServeMux() + parker.HandleFunc("/parker/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + parker.HandleFunc("/parker/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + parker.HandleFunc("/parker/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"u"}}`)) + }) + parkerSrv := httptest.NewServer(parker) + defer parkerSrv.Close() + + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&mutationHits, 1) + if n == 1 { + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parkerSrv.URL + `/parker/sessions","statusPathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}","exchangePathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]}`)) + return + } + headerAfterRetry = r.Header.Get("x-elevated-token") + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer gql.Close() + + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parkerSrv.URL, HTTP: parkerSrv.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Runner: runner, + // `go test` has no TTY, so the default sensor would report + // non-interactive and (correctly) refuse to open a challenge. + Interactive: func() bool { return true }, + })}, + }) + + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + out, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(out), `"success":true`) { + t.Errorf("expected success after replay; body = %s", out) + } + if mutationHits != 2 { + t.Errorf("mutation hits = %d, want 2 (one bounce + one retry)", mutationHits) + } + if headerAfterRetry != "elev" { + t.Errorf("retry header = %q, want elev", headerAfterRetry) + } +} + +func TestRechallengeSurfacesOriginalErrorOnFlowFailure(t *testing.T) { + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte("parker boom")) + })) + defer parker.Close() + gqlHits := int32(0) + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&gqlHits, 1) + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parker.URL + `/x","statusPathTemplate":"` + parker.URL + `/x/{challengeId}","exchangePathTemplate":"` + parker.URL + `/x/{challengeId}/y","elevatedHeaderName":"x-elevated-token"}}}]}`)) + })) + defer gql.Close() + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, Runner: runner, Stderr: io.Discard, + // Interactive so the failure under test is Parker's HTTP 500 and + // not the non-interactive refusal that precedes it. + Interactive: func() bool { return true }, + })}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + // Mutation should NOT have retried (gqlHits == 1). + if gqlHits != 1 { + t.Errorf("gql hits = %d, want 1 (no retry when Parker fails)", gqlHits) + } + out, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(out), "elevated-permission-required") { + t.Errorf("original error must be surfaced; body = %s", out) + } +} + +func TestRechallengeUsesConfigInteractivityProvider(t *testing.T) { + parker := http.NewServeMux() + parker.HandleFunc("/parker/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + parker.HandleFunc("/parker/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + parker.HandleFunc("/parker/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"u"}}`)) + }) + parkerSrv := httptest.NewServer(parker) + defer parkerSrv.Close() + + mutationHits := int32(0) + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&mutationHits, 1) + if n == 1 { + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parkerSrv.URL + `/parker/sessions","statusPathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}","exchangePathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]}`)) + return + } + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer gql.Close() + + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parkerSrv.URL, HTTP: parkerSrv.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + + var interactiveCalls int32 + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Runner: runner, + // Returning true is what makes this test meaningful: the default + // sensor reports non-interactive under `go test` (no TTY), so the + // flow can only reach Parker if the injected provider was consulted. + Interactive: func() bool { + atomic.AddInt32(&interactiveCalls, 1) + return true + }, + })}, + }) + + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + // Must have entered the elevated-flow code path (one bounce + one retry). + if mutationHits < 2 { + t.Errorf("mutation hits = %d, want >= 2 (elevated flow must run for this assertion to be meaningful)", mutationHits) + } + if got := atomic.LoadInt32(&interactiveCalls); got < 1 { + t.Errorf("Interactive provider never called; got %d calls", got) + } +} + +// elevatedBouncer serves a GraphQL endpoint that answers every mutation with an +// elevated-permission-required error pointing at parkerURL. +func elevatedBouncer(t *testing.T, parkerURL string, hits *int32) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits != nil { + atomic.AddInt32(hits, 1) + } + w.Write([]byte(`{"errors":[{"message":"You do not have permission to perform this action.","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + + parkerURL + `/x","statusPathTemplate":"` + parkerURL + `/x/{challengeId}","exchangePathTemplate":"` + + parkerURL + `/x/{challengeId}/y","elevatedHeaderName":"x-elevated-token"}}}]}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +const mutationBody = `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + +// TestRechallengeSurfacesStepUpFailureReason: when step-up fails, the reason was +// dropped on the floor (`if runErr != nil { return resp, nil }`) and the user saw +// only the generic "you do not have permission" error the server had already +// sent. The diagnosis was in hand and thrown away — same class of bug as +// 78d0a615 in parity/parker_discovery.go. +func TestRechallengeSurfacesStepUpFailureReason(t *testing.T) { + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + w.Write([]byte(`{"error":"step-up provider unavailable"}`)) + })) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(context.Context, time.Duration) error { return nil }, + }, + Interactive: func() bool { return true }, + })}, + }) + req, _ := http.NewRequest("POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + + got := stderr.String() + for _, want := range []string{ + "updateDefensiveModeStatus", // which operation + "503", // what the step-up service said + "step-up provider unavailable", // why + } { + if !strings.Contains(got, want) { + t.Errorf("step-up failure notice must mention %q; got %q", want, got) + } + } + // The original GraphQL error still has to reach the error middleware. + if !strings.Contains(string(body), "elevated-permission-required") { + t.Errorf("original error must still be surfaced; body = %s", body) + } +} + +// TestRechallengeFailureReasonCannotLeakToken: the surfaced text is +// server-controlled and reaches CI logs and the telemetry exit hook. Parker +// echoes request context into some payloads, so the worst case is the response +// body containing the caller's own bearer token. +func TestRechallengeFailureReasonCannotLeakToken(t *testing.T) { + const bearer = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyaW5hdCJ9.c2lnbmF0dXJlLWhlcmU" + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"error":"upstream refused","request":{"authorization":"` + + r.Header.Get("Authorization") + `"}}`)) + })) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{ + APIHost: parker.URL, HTTP: parker.Client(), BearerToken: bearer, + }, + TokenCache: cache, + Sleep: func(context.Context, time.Duration) error { return nil }, + }, + Interactive: func() bool { return true }, + })}, + }) + req, _ := http.NewRequest("POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if strings.Contains(stderr.String(), bearer) { + t.Fatalf("bearer token leaked into the surfaced step-up failure: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "upstream refused") { + t.Errorf("redaction must not eat the diagnosis; got %q", stderr.String()) + } +} + +// TestRechallengeNonInteractiveReturnsPromptly is the middleware-level watchdog +// for the CI hang: a mutation that trips step-up under --non-interactive must +// come back with an error immediately instead of polling Parker until the +// verification session expires. It FAILS on timeout rather than hanging, so a +// regression shows up as a red build and not as a stuck job. +func TestRechallengeNonInteractiveReturnsPromptly(t *testing.T) { + var parkerHits int32 + parkerMux := http.NewServeMux() + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + parkerMux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&parkerHits, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + hour + `"}`)) + }) + parkerMux.HandleFunc("/x/c1", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&parkerHits, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + hour + `","pollIntervalSeconds":0}`)) + }) + parker := httptest.NewServer(parkerMux) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + }, + Interactive: func() bool { return false }, + Wait: func() bool { return false }, + })}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + + done := make(chan error, 1) + go func() { + _, err := c.Do(req) + done <- err + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("Do: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("mutation did not return within 5s under --non-interactive: " + + "step-up is polling a challenge nobody can approve (this is the CI hang)") + } + + if n := atomic.LoadInt32(&parkerHits); n != 0 { + t.Errorf("Parker was called %d times; a non-interactive run must not open a "+ + "verification session no human can complete", n) + } + if !strings.Contains(stderr.String(), "non-interactive") { + t.Errorf("user must be told why step-up was refused; stderr = %q", stderr.String()) + } +} + +func TestRechallengeIgnoresUnrelatedErrors(t *testing.T) { + calls := int32(0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Write([]byte(`{"errors":[{"message":"validation failed","extensions":{"code":"BAD_REQUEST"}}]}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: newTestRechallengeCache(), + })}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (no rechallenge for unrelated errors)", calls) + } +} diff --git a/internal/gql/retry.go b/internal/gql/retry.go new file mode 100644 index 000000000..f6525bc25 --- /dev/null +++ b/internal/gql/retry.go @@ -0,0 +1,111 @@ +package gql + +import ( + "bytes" + "errors" + "io" + "net/http" + "syscall" + "time" +) + +type RetryConfig struct { + MaxAttempts int + InitialDelay time.Duration + MaxDelay time.Duration + NoDelay bool // tests set this to skip sleeps +} + +func defaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxAttempts: 5, + InitialDelay: 1 * time.Second, + MaxDelay: 5 * time.Second, + } +} + +func NewRetryMiddleware(cfg RetryConfig) Middleware { + if cfg.MaxAttempts == 0 { + cfg = defaultRetryConfig() + } + return func(next Doer) Doer { + return &retryDoer{next: next, cfg: cfg} + } +} + +type retryDoer struct { + next Doer + cfg RetryConfig +} + +func (r *retryDoer) Do(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + retryable := isRetryableOperation(body) + var resp *http.Response + var lastErr error + for attempt := 1; attempt <= r.cfg.MaxAttempts; attempt++ { + if attempt > 1 { + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + resp, lastErr = r.next.Do(req) + if !shouldRetry(resp, lastErr, retryable, attempt, r.cfg.MaxAttempts) { + return resp, lastErr + } + if resp != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + if !r.cfg.NoDelay { + time.Sleep(backoff(attempt, r.cfg.InitialDelay, r.cfg.MaxDelay)) + } + } + return resp, lastErr +} + +func shouldRetry(resp *http.Response, err error, retryable bool, attempt, maxAttempts int) bool { + if !retryable { + return false + } + if attempt >= maxAttempts { + return false + } + if err != nil { + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + return false + } + if resp == nil { + return false + } + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { + return false + } + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + return true + } + return false +} + +func isRetryableOperation(body []byte) bool { + op, err := ParseOperationFromBody(body) + return err == nil && !op.IsMutation +} + +func backoff(attempt int, initial, max time.Duration) time.Duration { + d := initial * time.Duration(1<<uint(attempt-1)) + if d > max { + return max + } + return d +} diff --git a/internal/gql/retry_test.go b/internal/gql/retry_test.go new file mode 100644 index 000000000..208454900 --- /dev/null +++ b/internal/gql/retry_test.go @@ -0,0 +1,230 @@ +package gql + +import ( + json "encoding/json/v2" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func graphqlBody(t *testing.T, operationName, query string) string { + t.Helper() + b, err := json.Marshal(map[string]any{ + "operationName": operationName, + "query": query, + }) + if err != nil { + t.Fatalf("marshal GraphQL body: %v", err) + } + return string(b) +} + +func TestRetryQueryOn5xx(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + w.WriteHeader(503) + return + } + w.WriteHeader(200) + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func TestNoRetryOnMutation(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(503) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"DoThing","query":"mutation DoThing{doThing{ok}}"}`)) + c.Do(req) + if calls != 1 { + t.Errorf("mutation must not retry; calls = %d, want 1", calls) + } +} + +func TestGeneratedMutationNeverRetries(t *testing.T) { + for _, status := range []int{http.StatusInternalServerError, http.StatusTooManyRequests} { + t.Run(http.StatusText(status), func(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(status) + })) + defer srv.Close() + + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader( + graphqlBody(t, "AbortMediaImport", AbortMediaImport_Operation), + )) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, _ := c.Do(req) + if resp != nil { + resp.Body.Close() + } + if calls != 1 { + t.Fatalf("generated mutation status %d calls = %d, want 1", status, calls) + } + }) + } +} + +func TestUnparseableOperationNeverRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`not-json`)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, _ := c.Do(req) + if resp != nil { + resp.Body.Close() + } + if calls != 1 { + t.Fatalf("unparseable operation calls = %d, want 1", calls) + } +} + +func TestGeneratedMultilineQueryStillRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader( + graphqlBody(t, "Me", Me_Operation), + )) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if calls != 3 { + t.Fatalf("generated query calls = %d, want 3", calls) + } +} + +func TestNoRetryOn4xxExcept429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(401) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 1 { + t.Errorf("4xx (not 429) must not retry; calls = %d, want 1", calls) + } +} + +func TestRetryOn429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.WriteHeader(429) + return + } + w.WriteHeader(200) + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 2 { + t.Errorf("429 must retry; calls = %d, want 2", calls) + } +} + +func TestRetryStopsAfterMaxAttempts(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(503) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 3, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 3 { + t.Errorf("retry must stop at MaxAttempts; calls = %d, want 3", calls) + } +} diff --git a/internal/gql/schema.gql b/internal/gql/schema.gql new file mode 100644 index 000000000..723e7270a --- /dev/null +++ b/internal/gql/schema.gql @@ -0,0 +1,9311 @@ +"""Controls the rate of traffic.""" +directive @rateLimit( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +"""Controls the rate of traffic.""" +directive @rateLimitPerModel( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +"""Controls the rate of traffic.""" +directive @rateLimitPerModelAndUser( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +directive @isVIP on FIELD_DEFINITION | OBJECT + +directive @hasPermission on FIELD_DEFINITION + +directive @requireElevatedPermission(operationDomain: ElevatedPermissionOperationDomain) on OBJECT | FIELD_DEFINITION + +directive @vipCliRequiredVersion(version: String!) on FIELD_DEFINITION | OBJECT + +"""Marks the audiences that can access a field.""" +directive @audience(values: [ApiAudience!]!) on FIELD_DEFINITION + +"""Assigns a field to a public API domain.""" +directive @domain(name: ApiDomain!) on FIELD_DEFINITION + +enum ElevatedPermissionOperationDomain { + USER_MANAGEMENT +} + +"""Input for starting the Salesforce OAuth flow for Agentforce.""" +input StartAgentforceOAuthInput { + """The site ID to authorize for Agentforce.""" + siteId: Int! +} + +"""The result of starting the Salesforce OAuth flow for Agentforce.""" +type StartAgentforceOAuthPayload { + """The Salesforce authorization URL to send the user to.""" + authorizationUrl: String! + + """The OAuth state value to verify on completion.""" + state: String! +} + +"""Input for completing the Salesforce OAuth flow for Agentforce.""" +input CompleteAgentforceOAuthInput { + """The authorization code returned by Salesforce.""" + code: String! + + """The OAuth state value returned by Salesforce.""" + state: String! +} + +"""The result of completing the Salesforce OAuth flow for Agentforce.""" +type CompleteAgentforceOAuthPayload { + """The ingestion API endpoint configured for Agentforce.""" + ingestionApiEndpoint: String! + + """The Salesforce instance URL connected to Agentforce.""" + salesforceInstanceUrl: String! +} + +input GenerateAgentforceSetupUrlInput { + """The unique ID of the Environment""" + siteId: Int! + + """Blog ID for the setup wizard. Use 1 for single-site environments.""" + blogId: Int! + + """Callback URL for setup completion""" + callbackUrl: String! +} + +type GenerateAgentforceSetupUrlPayload { + """The HMAC-signed Salesforce Lightning URL for the WP Agent Setup wizard""" + setupUrl: String! +} + +"""The root mutation type for the public API.""" +type Mutation { + """ + Start the Salesforce OAuth flow for Agentforce and return the authorization URL. + """ + startAgentforceOAuth( + """The site to authorize for Agentforce.""" + input: StartAgentforceOAuthInput! + ): StartAgentforceOAuthPayload! + + """ + Complete the Salesforce OAuth flow for Agentforce and persist credentials. + """ + completeAgentforceOAuth( + """The authorization code and state returned by Salesforce.""" + input: CompleteAgentforceOAuthInput! + ): CompleteAgentforceOAuthPayload! + + """ + Generate a signed WP Agent Setup wizard URL with HMAC-SHA256 query string protection. + """ + generateAgentforceSetupUrl( + """The parameters for generating a signed setup URL.""" + input: GenerateAgentforceSetupUrlInput! + ): GenerateAgentforceSetupUrlPayload! + + """Enable a feature flag for an application.""" + enableFeature( + """The application and feature values to enable.""" + input: AppFeatureInput + ): AppFeaturePayload + + """Disable a feature flag for an application.""" + disableFeature( + """The application and feature values to disable.""" + input: AppFeatureInput + ): AppFeaturePayload + + """Activate a certificate for all domains on a site""" + activateCertificateBySite( + """The site and certificate values used for activation.""" + input: ActivateCertificateBySiteInput + ): ActivateCertificateBySitePayload + + """Debug page cache object""" + debugPageCache( + """The application, environment, URL, and request details to debug.""" + input: DebugPageCacheInput + ): DebugPageCachePayload! + + """Purge page cache object(s)""" + purgePageCache( + """The application, environment, and URLs to purge.""" + input: PurgePageCacheInput + ): PurgePageCachePayload! + + """Start a custom deploy on an environment.""" + startCustomDeploy( + """The environment and artifact details for the custom deploy.""" + input: AppEnvironmentCustomDeployInput + ): AppEnvironmentCustomDeployPayload + + """Enable custom deploys on an environment.""" + enableCustomDeploy( + """The application and environment to enable.""" + input: AppEnvironmentEnableDisableCustomDeployInput + ): AppEnvironmentEnableDisableCustomDeployPayload + + """Disable custom deploys on an environment.""" + disableCustomDeploy( + """The application and environment to disable.""" + input: AppEnvironmentEnableDisableCustomDeployInput + ): AppEnvironmentEnableDisableCustomDeployPayload + + """Generate a custom deploy access token.""" + generateCustomDeployAccess( + """The environments the token should allow access to.""" + input: GenerateCustomDeployAccessInput + ): GenerateCustomDeployAccessPayload + + """Validate custom deploy access for an application and environment.""" + validateCustomDeployAccess( + """The application and environment identifiers to validate.""" + input: ValidateCustomDeployAccessInput + ): ValidateCustomDeployAccessPayload + + """Manage Integration""" + manageIntegration( + """The integration scope, status, and configuration to apply.""" + input: ManageIntegrationInput! + ): Integration + + """Invite a user to an organization""" + createInvitation( + """The organization, email addresses, and permissions for the invitation.""" + input: CreateInvitationInput + ): CreateInvitationPayload! + + """Accept an invitation to an organization""" + acceptInvitation( + """The invitation code to accept.""" + input: AcceptInvitationInput + ): AcceptInvitationPayload! + + """Resend an invitation to an organization""" + resendInvitation( + """The invitation to resend.""" + input: ResendInvitationInput + ): ResendInvitationPayload! + + """Cancel an invitation to an organization""" + cancelInvitation( + """The invitation to cancel.""" + input: CancelInvitationInput + ): CancelInvitationPayload! + + """Set a user's organization role.""" + setUserOrganizationRole( + """The user and organization role assignment to apply.""" + input: UpdateUserOrganizationRoleInput + ): UpdateUserOrganizationRolePayload! + + """Set a user's application roles.""" + setUserApplicationRoles( + """The application role assignments to apply.""" + input: SetUserApplicationRolesInput + ): SetUserApplicationRolesPayload! + + """Custom Metric Thresholds management""" + setMetricThresholds( + """The environment, metric, and thresholds to create.""" + input: SetOrUpdateMetricThresholdsInput + ): SetOrUpdateMetricThresholdPayload + + """Update metric thresholds for an environment.""" + updateMetricThresholds( + """The environment, metric, and thresholds to update.""" + input: SetOrUpdateMetricThresholdsInput + ): SetOrUpdateMetricThresholdPayload + + """Delete metric thresholds for an environment.""" + deleteMetricThresholds( + """The environment, metric, and event type to delete.""" + input: DeleteMetricThresholdsInput + ): DeleteMetricThresholdsPayload + + """Enable New Relic on an environment.""" + enableNewRelic( + """The application and environment to enable New Relic on.""" + input: AppEnvironmentEnableNewRelicInput + ): AppEnvironmentEnableNewRelicPayload + + """Disable New Relic on an environment.""" + disableNewRelic( + """The application and environment to disable New Relic on.""" + input: AppEnvironmentDisableNewRelicInput + ): AppEnvironmentDisableNewRelicPayload + + """Add a New Relic user to an environment.""" + addNewRelicUser( + """The application, environment, and user details to add.""" + input: AppEnvironmentAddNewRelicUserInput + ): AppEnvironmentAddNewRelicUserPayload + + """Delete a New Relic user from an environment.""" + deleteNewRelicUser( + """The application, environment, and New Relic user to delete.""" + input: AppEnvironmentDeleteNewRelicUserInput + ): AppEnvironmentDeleteNewRelicUserPayload + + """Create a notification recipient.""" + addNotificationRecipient( + """The notification recipient to create.""" + input: AddNotificationRecipientInput + ): AddNotificationRecipientPayload + + """Update a notification recipient.""" + updateNotificationRecipient( + """The notification recipient changes to apply.""" + input: UpdateNotificationRecipientInput + ): UpdateNotificationRecipientPayload! + + """Delete a notification recipient.""" + deleteNotificationRecipient( + """The notification recipient to delete.""" + input: DeleteNotificationRecipientInput + ): DeleteNotificationRecipientPayload! + + """Create a notification subscription.""" + addNotificationSubscription( + """The notification subscription to create.""" + input: AddNotificationSubscriptionInput + ): AddNotificationSubscriptionPayload! + + """Delete a notification subscription.""" + deleteNotificationSubscription( + """The notification subscription to delete.""" + input: DeleteNotificationSubscriptionInput + ): DeleteNotificationSubscriptionPayload! + + """Update a notification subscription.""" + updateNotificationSubscription( + """The notification subscription changes to apply.""" + input: UpdateNotificationSubscriptionInput + ): UpdateNotificationSubscriptionPayload! + + """Send a test notification to a recipient.""" + sendTestNotification( + """The recipient and message details for the test notification.""" + input: SendTestNotificationInput + ): SendTestNotificationPayload! + + """ + Generate a Google Sheets access token from service account credentials. + """ + generateGoogleSheetsAccessToken( + """The service account credentials to exchange.""" + input: GenerateGoogleSheetsAccessTokenInput! + ): GenerateGoogleSheetsAccessTokenPayload! + + """Roll an environment back to a previous deployment.""" + rollback( + """The application, environment, and target deployment for the rollback.""" + input: RollbackInput + ): RollbackPayload! + + """Create a certificate signing request.""" + createCSR( + """The client, domain, and CSR details to generate.""" + input: CreateCSRInput + ): CreateCSRPayload! + + """Add a certificate to a domain.""" + addCertificate( + """The certificate details to add.""" + input: AddCertificateInput + ): AddCertificatePayload! + + """Update an existing certificate.""" + updateCertificate( + """The certificate details to update.""" + input: UpdateCertificateInput + ): UpdateCertificatePayload! + + """Activate a certificate on one or more domains.""" + activateCertificate( + """The domains and certificate to activate.""" + input: ActivateCertificateInput + ): ActivateCertificatePayload! + + """Decode a certificate signing request.""" + decodeCSR( + """The CSR string to decode.""" + input: DecodeCSRInput + ): CSRDecoded! + + """Delete a certificate.""" + deleteCertificate( + """The certificate to delete.""" + input: DeleteCertificateInput + ): DeleteCertificatePayload! + + """Purpose Token Management""" + deactivatePurposeToken( + """The purpose token to deactivate.""" + input: DeactivatePurposeTokenInput + ): DeactivatePurposeTokenPayload! + + """Email Verification Token Management""" + generateEmailVerificationToken( + """The email address to generate a verification token for.""" + input: GenerateEmailVerificationTokenInput! + ): EmailVerificationTokenPayload! + + """Validate an email verification token.""" + validateEmailVerificationToken( + """The email verification token to validate.""" + input: ValidateEmailVerificationTokenInput! + ): ValidateEmailVerificationTokenPayload! + + """Cancel a pending email verification token.""" + cancelPendingEmailVerificationToken( + """The pending email verification token to cancel.""" + input: CancelEmailVerificationTokenInput + ): CancelPendingEmailVerificationTokenPayload! + + """Create a user.""" + createUser( + """The user values to create.""" + input: CreateUserInput + ): CreateUserPayload! + + """ + Remove a user from an organization (removes all roles and applications permissions) + """ + removeUserFromOrganization( + """The user and organization to remove.""" + input: RemoveUserFromOrganizationInput + ): RemoveUserFromOrganizationPayload! + + """Update a user's GitHub username or email address""" + updateUser( + """The user changes to apply.""" + input: UpdateUserInput + ): UpdateUserPayload! + + """Trigger a sync for an application environment.""" + syncEnvironment( + """The application and environment to sync.""" + input: AppEnvironmentSyncInput + ): AppEnvironmentSyncPayload! + + """Generate a new token for the current user.""" + generateUserToken( + """The token lifetime settings.""" + input: UserTokenGenerationInput + ): UserTokenGenerationPayload! + + """Deactivate one of the current user's tokens.""" + deactivateUserToken( + """The token to deactivate.""" + input: DeactivateUserTokenInput + ): DeactivateUserTokenPayload! + + """Abort a media import.""" + abortMediaImport( + """The media import to abort.""" + input: AppEnvironmentAbortMediaImportInput + ): AppEnvironmentAbortMediaImportPayload! + + """Activate a Let's Encrypt TLS certificate for a domain.""" + activateLetsEncryptOnDomainForAppEnvironment( + """The environment and domain to activate Let's Encrypt on.""" + input: AppEnvironmentActivateLetsEncryptOnDomainInput + ): AppEnvironmentActivateLetsEncryptOnDomainPayload! + + """Add basic auth users to an environment.""" + addBasicAuth( + """The basic auth users to add.""" + input: AppEnvironmentBasicAuthInput + ): AppEnvironmentBasicAuthPayload! + + """Add a domain to an environment.""" + addDomainToAppEnvironment( + """The environment and domain to add.""" + input: AppEnvironmentAddDomainInput + ): AppEnvironmentAddDomainPayload! + + """Add an environment variable to an application environment.""" + addEnvironmentVariable( + """The application, environment, and variable values to add.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Sync request stats for an environment.""" + addRequestStats( + """The environment and date range to sync.""" + input: AppEnvironmentAddRequestStatsInput + ): AppEnvironmentAddRequestStatsPayload + + """Stop a running WP-CLI command""" + cancelWPCLICommand( + """The GUID of the command to cancel.""" + input: CancelWPCLICommandInput + ): CancelWPCLICommandPayload! + + """Repository Management""" + changeRepo( + """The application, environment, and branch to switch to.""" + input: CodebaseChangeRepoInput + ): CodebaseChangeRepoResult! + + """Complete an Elasticsearch upgrade.""" + completeElasticsearchUpgrade( + """The environment whose upgrade should be completed.""" + input: AppEnvironmentCompleteElasticsearchUpgradeInput! + ): AppEnvironmentElasticsearchUpgradePayload! + + """ + Create a new non-production environment as a child of a production environment + """ + createChildEnvironment( + """The parent application and child environment settings.""" + input: AppEnvironmentCreateChildEnvironmentInput! + ): AppEnvironmentCreateChildEnvironmentPayload! + + """Create a new WASM edge worker on an environment.""" + createEdgeWorker( + """The edge worker to create.""" + input: CreateEdgeWorkerInput! + ): EdgeWorker + + """Remove a domain from an environment.""" + deactivateDomainOnAppEnvironment( + """The environment and domain to deactivate.""" + input: AppEnvironmentDeactivateDomainInput + ): AppEnvironmentDeactivateDomainPayload! + + """Delete backup shipping configuration.""" + deleteBackupShippingConfigV2( + """The backup shipping configuration to delete.""" + input: AppEnvironmentBackupShippingDeleteInput + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Delete basic auth users from an environment.""" + deleteBasicAuth( + """The basic auth users to delete.""" + input: AppEnvironmentBasicAuthDeleteInput + ): AppEnvironmentBasicAuthPayload! + + """Permanently delete a WASM edge worker.""" + deleteEdgeWorker( + """The edge worker to delete.""" + input: DeleteEdgeWorkerInput! + ): Boolean + + """Delete an environment variable from an application environment.""" + deleteEnvironmentVariable( + """The application, environment, and variable values to delete.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Delete an identity provider.""" + deleteIdentityProvider( + """The identity provider to delete.""" + input: DeleteIdentityProviderInput + ): DeleteIdentityProviderPayload! + + """Delete log shipping configuration.""" + deleteLogShippingConfigV2( + """The log shipping configuration to delete.""" + input: AppEnvironmentLogShippingDeleteInput + ): AppEnvironmentLogShippingOperationResultPayload! + + """Delete an organization auth domain.""" + deleteOrganizationAuthDomain( + """The auth domain to delete.""" + input: OrganizationAuthDomainDeleteInput + ): OrganizationAuthDomainDeletePayload! + + """Disable enforced SSO access for an organization.""" + disableEnforceSSOAccess( + """The organization ID to disable enforced SSO access for.""" + organizationId: Int! + ): Boolean! + + """Disable encryption for an identity provider.""" + disableIdentityProviderEncryption( + """The identity provider to disable encryption for.""" + input: EnableIdentityProviderEncryptionInput + ): EnableIdentityProviderEncryptionPayload! + + """Edit basic auth users on an environment.""" + editBasicAuth( + """The basic auth users to update.""" + input: AppEnvironmentBasicAuthInput + ): AppEnvironmentBasicAuthPayload! + + """Enforce SSO Access""" + enableEnforceSSOAccess( + """The organization ID to require SSO access for.""" + organizationId: Int! + ): Boolean! + + """Enable encryption for an identity provider.""" + enableIdentityProviderEncryption( + """The identity provider to enable encryption for.""" + input: EnableIdentityProviderEncryptionInput + ): EnableIdentityProviderEncryptionPayload! + + """Enable launch mode for an environment.""" + enableLaunchMode( + """The environment and launch mode settings to apply.""" + input: AppEnvironmentEnableLaunchModeInput + ): AppEnvironmentEnableLaunchModePayload + + """Enable phpMyAdmin for an environment.""" + enablePHPMyAdmin( + """The environment to enable phpMyAdmin for.""" + input: EnablePhpMyAdminInput + ): EnablePhpMyAdminPayload + + """Enqueue an Elasticsearch upgrade.""" + enqueueElasticsearchUpgrade( + """The environment and version to upgrade.""" + input: AppEnvironmentEnqueueElasticsearchUpgradeInput! + ): AppEnvironmentElasticsearchUpgradePayload! + + """Generate a presigned download URL for a copied database backup.""" + generateDBBackupCopyUrl( + """The backup copy to generate a URL for.""" + input: AppEnvironmentGenerateDBBackupCopyUrlInput + ): AppEnvironmentGenerateDBBackupCopyUrlPayload + + """Generate a live backup copy download URL.""" + generateLiveBackupCopyDownloadURL( + """The live backup copy to generate a URL for.""" + input: AppEnvironmentLiveBackupCopyDownloadURLInput! + ): AppEnvironmentLiveBackupCopyDownloadURLPayload + + """Generate a signed URL for a media export artifact.""" + generateMediaExportSignedUrl( + """The export target and identifiers to generate a URL for.""" + input: AppEnvironmentGenerateMediaExportSignedUrlInput + ): AppEnvironmentGenerateMediaExportSignedUrlPayload + + """Generate temporary phpMyAdmin access for an environment.""" + generatePHPMyAdminAccess( + """The environment to generate access for.""" + input: GeneratePhpMyAdminAccessInput + ): GeneratePhpMyAdminAccessPayload + + """Mark an application environment as launched.""" + launchApplication( + """The application and environment to update.""" + input: AppEnvironmentLaunchedInput + ): AppEnvironmentLaunchedPayload + + """Replace all auth domains for an organization.""" + replaceOrganizationAuthDomains( + """The organization and domains to store.""" + input: OrganizationAuthDomainReplaceInput + ): OrganizationAuthDomainReplacePayload! + + """Request a feature upgrade for an organization or application.""" + requestFeatureUpgrade( + """The organization, optional application, and feature to request.""" + input: RequestFeatureUpgradeInput + ): RequestFeatureUpgradePayload + + """Retire a non-production environment.""" + retireEnvironment( + """The environment to retire.""" + input: AppEnvironmentRetireInput + ): AppEnvironmentRetirePayload! + + """Create or update an identity provider.""" + saveIdentityProvider( + """The identity provider values to save.""" + input: SaveIdentityProviderInput + ): SaveIdentityProviderPayload! + + """Create or update an organization auth domain.""" + saveOrganizationAuthDomain( + """The auth domain values to save.""" + input: OrganizationAuthDomainCreateInput + ): OrganizationAuthDomainPayload! + + """Enable or disable an existing WASM edge worker.""" + setEdgeWorkerActive( + """The edge worker and desired active state.""" + input: SetEdgeWorkerActiveInput! + ): EdgeWorker + + """Update validation settings for an identity provider.""" + setIdentityProviderValidations( + """The identity provider validation settings to apply.""" + input: SetIdentityProviderValidationsInput! + ): SetIdentityProviderValidationsPayload! + + """Start copying a database backup.""" + startDBBackupCopy( + """The backup copy request.""" + input: AppEnvironmentStartDBBackupCopyInput + ): AppEnvironmentStartDBBackupCopyPayload! + + """Start importing data into an environment.""" + startImport( + """The import settings to apply.""" + input: AppEnvironmentImportInput + ): AppEnvironmentImportPayload! + + """Start a live backup copy.""" + startLiveBackupCopy( + """The live backup copy configuration.""" + input: LiveBackupCopyConfigInput! + ): AppEnvironmentStartLiveBackupCopyPayload! + + """Start a media export for an environment.""" + startMediaExport( + """The application, environment, and export options to use.""" + input: StartMediaExportInput + ): StartMediaExportPayload + + """Import media into an environment.""" + startMediaImport( + """The media import request.""" + input: AppEnvironmentStartMediaImportInput + ): AppEnvironmentMediaImportPayload + + """Switch the primary domain for an environment.""" + switchEnvironmentPrimaryDomain( + """The environment and domain to make primary.""" + input: AppEnvironmentPrimaryDomainSwitchInput + ): AppEnvironmentPrimaryDomainSwitchPayload! + + """Trigger Agentforce sync to push WordPress content to Salesforce""" + triggerAgentforceSync( + """The application, environment, and optional network site to sync.""" + input: TriggerAgentforceSyncInput! + ): TriggerAgentforceSyncPayload! + + """Trigger a database backup.""" + triggerDatabaseBackup( + """The database backup request.""" + input: AppEnvironmentTriggerDBBackupInput + ): AppEnvironmentTriggerDBBackupPayload! + + """Execute a WP-CLI command on an environment.""" + triggerWPCLICommandOnAppEnvironment( + """The environment and command to run.""" + input: AppEnvironmentTriggerWPCLICommandInput + ): AppEnvironmentTriggerWPCLICommandPayload! + + """Update backup shipping configuration.""" + updateBackupShippingConfigV2( + """The backup shipping configuration to store.""" + input: AppEnvironmentBackupShippingV2Input + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Enable or disable backup shipping.""" + updateBackupShippingStatusV2( + """The backup shipping status to apply.""" + input: AppEnvironmentBackupShippingUpdateStatusInput + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Update the custom error page configuration for an environment.""" + updateCustomErrorPageConfig( + """The environment and custom error page settings to apply.""" + input: UpdateCustomErrorPageConfigInput! + ): CustomErrorPageConfig! + + """Update defensive mode configuration.""" + updateDefensiveModeConfig( + """The defensive mode configuration to store.""" + input: AppEnvironmentDefensiveModeConfigInput + ): AppEnvironmentDefensiveModeOperationResultPayload! + + """Enable or disable defensive mode.""" + updateDefensiveModeStatus( + """The defensive mode status to apply.""" + input: AppEnvironmentDefensiveModeUpdateStatusInput + ): AppEnvironmentDefensiveModeOperationResultPayload! + + """Update an existing WASM edge worker.""" + updateEdgeWorker( + """The edge worker changes to apply.""" + input: UpdateEdgeWorkerInput! + ): EdgeWorker + + """Update a multisite subsite domain.""" + updateEnvironmentSubsiteDomain( + """The subsite domain update to apply.""" + input: AppEnvironmentUpdateSubsiteDomainInput + ): AppEnvironmentUpdateSubsiteDomainPayload! + + """Update an environment variable on an application environment.""" + updateEnvironmentVariable( + """The application, environment, and variable values to update.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Update HSTS settings for an environment.""" + updateHSTSSettings( + """The HSTS settings to apply.""" + input: AppEnvironmentHSTSSettingsInput + ): AppEnvironmentHSTSSettingsPayload + + """Update IP-based access restrictions for an environment.""" + updateIPAccessRestrictions( + """The environment and IP access restriction settings to apply.""" + input: EdgeConfigUpdateIPAccessRestrictionsInput + ): EdgeConfigAccessRestrictionsIp + + """Update log shipping configuration.""" + updateLogShippingConfigV2( + """The log shipping configuration to store.""" + input: AppEnvironmentLogShippingV2Input + ): AppEnvironmentLogShippingOperationResultPayload! + + """Enable or disable log shipping.""" + updateLogShippingStatusV2( + """The log shipping status to apply.""" + input: AppEnvironmentLogShippingUpdateStatusInput + ): AppEnvironmentLogShippingOperationResultPayload! + + """Plugin Update""" + updatePlugin( + """The application, environment, and plugin version details to update.""" + input: CodebaseUpdatePluginInput + ): CodebaseUpdatePluginResult! + + """Update software settings for an application environment.""" + updateSoftwareSettings( + """The application, environment, and software version to update.""" + input: AppEnvironmentSoftwareSettingsInput + ): AppEnvironmentSoftwareSettings + + """Update user-agent-based access restrictions for an environment.""" + updateUserAgentAccessRestrictions( + """The environment and user-agent access restriction settings to apply.""" + input: EdgeConfigUpdateUserAgentAccessRestrictionsInput + ): EdgeConfigAccessRestrictionsUserAgent + + """Update the launch status for a WordPress site.""" + updateWPSiteLaunchStatus( + """The application, environment, site, and launch status to update.""" + input: WPSiteLaunchStatusInput + ): WPSiteLaunchStatusPayload! + + """Validate backup shipping configuration.""" + validateBackupShippingConfigV2( + """The backup shipping configuration to validate.""" + input: AppEnvironmentBackupShippingV2Input + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Validate log shipping configuration.""" + validateLogShippingConfigV2( + """The log shipping configuration to validate.""" + input: AppEnvironmentLogShippingV2Input + ): AppEnvironmentLogShippingOperationResultPayload! + + """Validate the current phpMyAdmin access token.""" + validatePHPMyAdminAccess: ValidatePhpMyAdminAccessPayload + + """Verify a DNS TXT record""" + verifyDnsTxtRecord( + """The domain to verify.""" + input: VerifyDnsTxtRecordInput + ): VerifyDnsTxtRecordPayload! +} + +""" +An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +""" +type App implements Model { + """The unique identifier for the application.""" + id: Int + + """The display name of the application.""" + name: String + + """ + The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + """ + environments( + """The environment ID to filter by.""" + id: Int + + """The environment name to filter by.""" + name: String + + """The environment type to filter by.""" + type: String + + """Filter environments by multisite state.""" + isMultisite: Boolean + + """Filter environments by launch state.""" + launched: Boolean + + """Exclude environments with these unique labels.""" + excludeUniqueLabels: [String] + ): [AppEnvironment] + + """ + The primary production environment for the application. This is the most common jump-off point for nested operational reads (commands, logs, events, deployments, backups, and more). + """ + primaryEnvironment: AppEnvironment + + """The source repository for the application in `owner/name` format.""" + repo: String + + """Repository metadata for the application's source code.""" + repository: GitRepository + + """The identifier of the organization that owns the application.""" + organizationId: Int + + """The organization that owns the application.""" + organization: Organization + + """The VIP support package assigned to the application.""" + supportPackage: String + + """The application platform type, such as WordPress or Node.js.""" + type: String + + """The internal numeric identifier for the application type.""" + typeId: Int + + """Pageview metrics for the application.""" + pageviews: Pageviews + + """The feature flags currently configured for the application.""" + features: [Feature] + + """When the application was created.""" + createdAt: String + + """Whether the application is currently active.""" + active: Boolean + + """The current VIP service status for the application.""" + serviceStatus: String + + """Permission checks for the current user on this application.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """Notification subscriptions configured for this application.""" + notificationSubscriptions( + """The maximum number of subscriptions to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter subscriptions by active status.""" + active: Boolean + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + + """ + Return organization-level subscriptions for the app's organization instead of app-level subscriptions. + """ + organizationSubscriptionsOnly: Boolean + + """Filter subscriptions by their VIN flag.""" + vin: Boolean + ): NotificationSubscriptionList + + """A single notification subscription on this application.""" + notificationSubscription( + """The notification subscription ID.""" + id: Int! + ): NotificationSubscription +} + +"""A paginated list of applications.""" +type AppList implements ModelList { + """The total number of matching applications.""" + total: Int + + """The cursor for the next page of applications.""" + nextCursor: String + + """The applications returned in the current page.""" + nodes: [App] + + """A legacy alias for `nodes`.""" + edges: [App] +} + +"""Input for enabling or disabling an application feature.""" +input AppFeatureInput { + """The application ID to update.""" + id: Int + + """The feature flag name.""" + name: String + + """The optional feature flag context.""" + context: String +} + +"""The application feature state after a feature mutation.""" +type AppFeaturePayload { + """The total number of features configured for the application.""" + total: Int + + """The features currently configured for the application.""" + features: [Feature] +} + +"""Input for activating a certificate across all domains on a site.""" +input ActivateCertificateBySiteInput { + """The site ID whose domains should receive the certificate.""" + clientSiteId: Int! + + """The certificate ID to activate.""" + certificateId: Int! + + """Whether to skip configuration reloads while applying the certificate.""" + skipConfigReloads: Boolean + + """Whether to bypass domain validation before activating the certificate.""" + bypassDomainValidation: Boolean +} + +"""The result of a site-wide certificate activation request.""" +type ActivateCertificateBySitePayload { + """The status returned by the activation request.""" + status: String! + + """The domain IDs that failed certificate activation.""" + failedDomains: [Int] +} + +"""An audit event recorded for an application or environment.""" +type AuditEvent { + """The unique identifier for the audit event.""" + id: String + + """The application associated with the event.""" + app: App + + """The environment ID associated with the event.""" + environmentId: Int + + """The environment associated with the event.""" + environment: AppEnvironment + + """The event type.""" + type: String + + """The event title.""" + title: String + + """The event description.""" + description: String + + """The actor that triggered the event.""" + actor: AuditEventActor + + """The target affected by the event.""" + target: AuditEventTarget + + """The source system that produced the event.""" + source: AuditEventSource + + """Additional metadata attached to the event.""" + meta: [AuditEventMeta] + + """When the event was recorded.""" + recordedTime: Date +} + +"""The actor that triggered an audit event.""" +type AuditEventActor { + """The unique identifier for the actor.""" + id: String + + """The actor type.""" + type: String + + """The permission associated with the actor, if any.""" + permission: String + + """The display name of the actor.""" + displayName: String + + """The avatar URL for the actor.""" + avatarUrl( + """The requested avatar width in pixels.""" + width: Int + ): String + + """Whether the actor is a VIP user.""" + isVIP: Boolean +} + +"""A paginated list of audit events.""" +type AuditEventList { + """The total number of matching audit events.""" + total: Int + + """The cursor for the next page of audit events.""" + nextCursor: String + + """The audit events returned in the current page.""" + nodes: [AuditEvent] + + """A legacy alias for `nodes`.""" + edges: [AuditEvent] +} + +"""A metadata entry attached to an audit event.""" +type AuditEventMeta { + """The metadata key.""" + key: String! + + """The metadata value.""" + value: String +} + +"""The source system that produced an audit event.""" +type AuditEventSource { + """The source type.""" + type: String + + """The source version.""" + version: String +} + +"""The target affected by an audit event.""" +type AuditEventTarget { + """The unique identifier for the target.""" + id: String + + """The target type.""" + type: String +} + +"""A count of audit events grouped by type.""" +type AuditEventCount { + """The event type being counted.""" + type: String + + """The number of events for the type.""" + count: Int +} + +"""The lifecycle states for a build.""" +enum BuildStatus { + """The build is queued and has not started yet.""" + QUEUED + + """The build is currently running.""" + RUNNING + + """The build finished with a failure.""" + FAILED + + """The build finished successfully.""" + SUCCESS +} + +"""A build executed for an application environment.""" +type Build implements Model { + """The unique identifier for the build.""" + id: Int + + """The vendor-specific build identifier.""" + vendor_id: Int + + """The current build status.""" + status: BuildStatus + + """When the build was queued.""" + queued_date: Date + + """When the build started.""" + start_date: Date + + """When the build finished.""" + finish_date: Date + + """The commit SHA built by this job.""" + commit_sha: String + + """The author of the commit built by this job.""" + commit_author: String! + + """When the built commit was created.""" + commit_time: Date! + + """The build log lines.""" + logs: [String] +} + +"""A paginated list of builds.""" +type BuildList implements ModelList { + """The total number of matching builds.""" + total: Int + + """The cursor for the next page of builds.""" + nextCursor: String + + """The builds returned in the current page.""" + nodes: [Build] +} + +"""Input for purging page cache entries.""" +input PurgePageCacheInput { + """The application ID whose cache should be purged.""" + appId: Int! + + """The environment ID whose cache should be purged.""" + environmentId: Int! + + """The URLs to purge from page cache.""" + urls: [String!]! +} + +"""The result of a page cache purge request.""" +type PurgePageCachePayload { + """The URLs that were targeted for purge.""" + urls: [String!]! + + """Whether the purge request succeeded.""" + success: Boolean! +} + +"""A request header to include in a cache debug request.""" +input RequestHeader { + """The header name.""" + name: String! + + """The header value.""" + value: String! +} + +"""A response header returned from a cache debug request.""" +type ResponseHeader { + """The header name.""" + name: String! + + """The header value.""" + value: String! +} + +"""A server response captured during cache debugging.""" +type ServerResponse { + """The response headers.""" + headers: [ResponseHeader!]! + + """The HTTP status code.""" + statusCode: Int! +} + +"""Input for debugging page cache behavior.""" +input DebugPageCacheInput { + """The application ID whose cache should be debugged.""" + appId: Int! + + """The environment ID whose cache should be debugged.""" + environmentId: Int! + + """The request headers to include in the debug request.""" + requestHeaders: [RequestHeader!] + + """The HTTP method to use for the debug request.""" + requestMethod: String + + """The point of presence to target, if supported.""" + pop: String + + """The URL to debug.""" + url: String! +} + +"""An insight produced by page cache debugging.""" +type DebugPageCacheInsight { + """The insight category.""" + category: String! + + """The insight rendered as HTML.""" + html: String! + + """Whether this is the final insight in the chain.""" + final: Boolean! + + """The display name of the insight.""" + name: String! + + """The insight type.""" + type: String! +} + +"""The result of a page cache debug request.""" +type DebugPageCachePayload { + """The edge response observed during debugging.""" + edge: ServerResponse + + """The insights generated during debugging.""" + insights: [DebugPageCacheInsight!] + + """The origin response observed during debugging.""" + origin: ServerResponse + + """Whether the debug request succeeded.""" + success: Boolean! + + """The URL that was debugged.""" + url: String! +} + +"""Input for enabling or disabling custom deploys on an environment.""" +input AppEnvironmentEnableDisableCustomDeployInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to update.""" + environmentId: Int! +} + +"""The result of enabling or disabling custom deploys.""" +type AppEnvironmentEnableDisableCustomDeployPayload { + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for starting a custom deploy.""" +input AppEnvironmentCustomDeployInput { + """The application ID, when required by the caller.""" + id: Int + + """The environment ID to deploy to.""" + environmentId: Int + + """The deployment artifact filename.""" + basename: String + + """The checksum of the deployment artifact.""" + checksum: String + + """The deploy message to record.""" + deployMessage: String +} + +"""The result of starting a custom deploy.""" +type AppEnvironmentCustomDeployPayload { + """The application being deployed.""" + app: App + + """Whether the custom deploy request succeeded.""" + success: Boolean + + """A human-readable message about the deploy request.""" + message: String +} + +"""Input for validating custom deploy access.""" +input ValidateCustomDeployAccessInput { + """The application identifier to validate.""" + app: String! + + """The environment identifier to validate.""" + env: String! +} + +"""The result of validating custom deploy access.""" +type ValidateCustomDeployAccessPayload { + """Whether the custom deploy access is valid.""" + success: Boolean + + """The resolved application ID.""" + appId: Int + + """The resolved environment ID.""" + envId: Int + + """The resolved environment type.""" + envType: String + + """The resolved unique environment label.""" + envUniqueLabel: String + + """The primary domain name for the environment.""" + primaryDomainName: String + + """Whether the environment is launched.""" + launched: Boolean +} + +"""Input for generating a custom deploy access token.""" +input GenerateCustomDeployAccessInput { + """The environment IDs the token should allow access to.""" + environmentIds: [Int!] +} + +"""The result of generating a custom deploy access token.""" +type GenerateCustomDeployAccessPayload { + """The generated custom deploy access token.""" + token: String + + """When the token expires.""" + expiresAt: Date +} + +"""A database partitioning dataset available for an environment.""" +type DBPartitioningDataset { + """The internal dataset name.""" + name: String + + """The display label for the dataset.""" + displayName: String +} + +"""A legacy deployment record for an application environment.""" +type Deploy { + """The unique identifier for the deployment.""" + id: Int + + """When the deployment finished.""" + deployed_at: String + + """The repository deployed.""" + repo: String + + """The branch that was deployed.""" + branch: String + + """The API user ID that initiated the deployment.""" + deployer_api_user_id: Int + + """The commits included in the deployment.""" + commits( + """The maximum number of commits to return.""" + first: Int + ): GitCommitList +} + +"""A paginated list of deployments.""" +type DeployList { + """The total number of matching deployments.""" + total: Int + + """The cursor for the next page of deployments.""" + nextCursor: String + + """The deployments returned in the current page.""" + nodes: [Deploy] + + """A legacy alias for `nodes`.""" + edges: [Deploy] +} + +"""The possible statuses for a deployment step.""" +enum DeploymentStepStatus { + """The step is currently running.""" + Running + + """The step is waiting to start.""" + Waiting + + """The step is pending.""" + Pending + + """The build phase is in progress.""" + Building + + """The build phase finished successfully.""" + BuildFinished + + """The build phase failed.""" + BuildError + + """The deployment phase is in progress.""" + Deploying + + """The step finished successfully.""" + Finished + + """The step finished with an error.""" + Error + + """The step was cancelled.""" + Cancelled +} + +"""A single step within a deployment.""" +type DeploymentStep { + """The step key.""" + step: String! + + """The current status of the step.""" + status: DeploymentStepStatus! + + """Whether the step is currently in progress.""" + inProgress: Boolean! + + """Whether the step is in an error state.""" + isError: Boolean! + + """When the step started.""" + startDate: Date + + """When the step finished.""" + finishDate: Date + + """The logs collected for the step.""" + logs: [String] + + """Whether logs are available for the current app type.""" + isLogsAvailableForAppType: Boolean + + """When the step logs expire.""" + logsExpireAt: Date +} + +"""A deployment for an application environment.""" +type Deployment implements Model { + """The unique identifier for the deployment.""" + id: Int! + + """The branch that was deployed.""" + branch: String! + + """The repository that was deployed.""" + repo: String! + + """The raw deployment status.""" + deployment_status: String! + + """When the deployment was triggered.""" + deployment_triggered_at: Date + + """When the deployment finished.""" + deployment_finished_at: Date + + """When the deployment record was created.""" + createdAt: Date + + """When the deployment was cancelled.""" + cancelledAt: Date + + """The deployed commit SHA.""" + commit_sha: String! + + """The author of the deployed commit.""" + commit_author: String + + """When the deployed commit was created.""" + commit_time: Date + + """The deployed commit description.""" + commit_description: String + + """The build associated with the deployment.""" + build: Build + + """Whether the deployment is in an error state.""" + isError: Boolean + + """Whether this is the latest deployment.""" + isLatest: Boolean + + """Whether the deployment is currently in progress.""" + inProgress: Boolean + + """The steps recorded for the deployment.""" + steps: [DeploymentStep] + + """Whether the deployment can be used for rollback.""" + isAvailableForRollback: Boolean + + """The user who initiated the deployment.""" + initiatedBy: User + + """The post-deploy actions job identifier.""" + postDeployActionsJob: String +} + +"""A paginated list of deployments.""" +type DeploymentList implements ModelList { + """The total number of matching deployments.""" + total: Int + + """The cursor for the next page of deployments.""" + nextCursor: String + + """The deployments returned in the current page.""" + nodes: [Deployment] +} + +"""A feature flag configured for an application.""" +type Feature implements Model { + """The unique identifier for the feature flag.""" + id: Int + + """The application ID that owns the feature flag.""" + appId: Int + + """The feature flag name.""" + name: String + + """The optional context for the feature flag.""" + context: String + + """Whether the feature flag is currently active.""" + active: Boolean +} + +"""A Git commit.""" +type GitCommit { + """The commit message headline.""" + messageHeadline: String + + """The commit message headline rendered as HTML.""" + messageHeadlineHTML: String + + """The commit message body.""" + messageBody: String + + """The commit message body rendered as HTML.""" + messageBodyHTML: String + + """The full commit message.""" + message: String + + """The full object ID for the commit.""" + oid: String + + """The abbreviated object ID for the commit.""" + abbreviatedOid: String + + """The author of the commit.""" + author: GitActor + + """When the commit was authored.""" + authoredDate: String + + """When the commit was committed.""" + committedDate: String + + """The number of lines deleted in the commit.""" + deletions: Int + + """The number of lines added in the commit.""" + additions: Int + + """The URL for the commit.""" + url: String +} + +"""The author or committer associated with a Git object.""" +type GitActor { + """The email address of the Git actor.""" + email: String + + """The display name of the Git actor.""" + name: String + + """The avatar URL for the Git actor.""" + avatarUrl( + """The requested avatar image size in pixels.""" + size: Int = 125 + ): String + + """The linked GitHub user, if available.""" + user: GitHubUser +} + +"""A Git repository.""" +type GitRepository { + """The repository name.""" + name: String + + """The repository owner or organization.""" + organization: String + + """The repository full name in `owner/name` format.""" + fullName: String + + """The source control platform.""" + platform: String + + """The HTML URL for the repository.""" + htmlUrl: String +} + +"""A paginated list of Git commits.""" +type GitCommitList { + """The cursor for the next page of commits.""" + nextCursor: String + + """The commits returned in the current page.""" + nodes: [GitCommit] + + """A legacy alias for `nodes`.""" + edges: [GitCommit] +} + +"""A GitHub issue or pull request comment.""" +type GitHubComment { + """The GitHub identifier for the comment.""" + id: ID + + """The API URL for the comment.""" + url: String + + """The HTML URL for the comment.""" + htmlUrl: String + + """The API URL for the related issue or pull request.""" + issueUrl: String + + """The GitHub user who authored the comment.""" + user: GitHubUser + + """When the comment was created.""" + createdAt: String + + """When the comment was last updated.""" + updatedAt: String + + """The comment body.""" + body: String +} + +"""A GitHub pull request.""" +type GitHubPullRequest implements Model { + """The GitHub identifier for the pull request.""" + id: Int + + """The pull request title.""" + title: String + + """The pull request number.""" + number: Int + + """The current pull request status.""" + status: String + + """The API URL for the pull request.""" + url: String + + """When the pull request was created.""" + createdAt: String + + """The labels applied to the pull request.""" + labels: [String] + + """The initial commit SHA for the pull request.""" + initialCommit: String + + """The total number of commits in the pull request.""" + totalCommits: Int + + """The API URL for the commits on the pull request.""" + commitsUrl: String + + """The GitHub user who opened the pull request.""" + user: GitHubUser + + """The API URL for the repository.""" + repositoryUrl: String + + """The API URL for the labels collection.""" + labelsUrl: String + + """The API URL for the comments collection.""" + commentsUrl: String + + """The API URL for the events collection.""" + eventsUrl: String + + """The pull request body.""" + body: String + + """Whether the pull request is locked.""" + locked: Boolean + + """The primary assignee on the pull request.""" + assignee: GitHubUser + + """The assignees on the pull request.""" + assignees: [GitHubUser] + + """The number of comments on the pull request.""" + comments: Int + + """When the pull request was last updated.""" + updatedAt: String + + """When the pull request was closed.""" + closedAt: String + + """VIP-specific metadata collected for the pull request.""" + vipMeta: VIPPRMeta +} + +"""A review comment on a GitHub pull request.""" +type GitHubPullRequestReviewComment { + """The GitHub identifier for the review comment.""" + id: ID + + """The API URL for the review comment.""" + url: String + + """The review ID associated with the comment.""" + pullRequest_review_id: Int + + """The diff hunk the comment refers to.""" + diffHunk: String + + """The file path the comment refers to.""" + path: String + + """The position within the diff.""" + position: Int + + """The original position within the diff.""" + originalPosition: Int + + """The commit SHA the comment refers to.""" + commitId: String + + """The original commit SHA the comment referred to.""" + originalCommitId: String + + """The GitHub user who authored the review comment.""" + user: GitHubUser + + """The review comment body.""" + body: String + + """When the review comment was created.""" + createdAt: String + + """When the review comment was last updated.""" + updatedAt: String + + """The HTML URL for the review comment.""" + htmlUrl: String + + """The API URL for the parent pull request.""" + pullRequestUrl: String +} + +"""A GitHub review on a pull request.""" +type GitHubReview { + """The GitHub identifier for the review.""" + id: ID + + """The GitHub user who submitted the review.""" + user: GitHubUser + + """The review body.""" + body: String + + """The review state.""" + state: String + + """The HTML URL for the review.""" + htmlUrl: String + + """The API URL for the parent pull request.""" + pullRequestUrl: String + + """When the review was submitted.""" + submittedAt: String + + """The commit SHA the review applies to.""" + commitId: String +} + +"""A GitHub user.""" +type GitHubUser { + """The GitHub identifier for the user.""" + id: ID + + """The GitHub login.""" + login: String + + """The avatar URL for the user.""" + avatarUrl: String + + """The user's gravatar identifier.""" + gravatarId: String + + """The API URL for the user.""" + url: String + + """The HTML URL for the user.""" + htmlUrl: String + + """The API URL for the user's followers.""" + followersUrl: String + + """The API URL template for the user's following list.""" + followingUrl: String + + """The API URL for the user's gists.""" + gistsUrl: String + + """The API URL template for the user's starred repositories.""" + starredUrl: String + + """The API URL for the user's subscriptions.""" + subscriptionsUrl: String + + """The API URL for the user's organizations.""" + organizationsUrl: String + + """The API URL for the user's repositories.""" + reposUrl: String + + """The API URL for the user's events.""" + eventsUrl: String + + """The API URL for the user's received events.""" + receivedEventsUrl: String + + """The GitHub account type.""" + type: String + + """Whether the user is a GitHub site admin.""" + siteAdmin: Boolean +} + +"""A paginated list of GitHub pull requests.""" +type GitHubPullRequestList implements ModelList { + """The total number of matching pull requests.""" + total: Int + + """The cursor for the next page of pull requests.""" + nextCursor: String + + """The pull requests returned in the current page.""" + nodes: [GitHubPullRequest] + + """A legacy alias for `nodes`.""" + edges: [GitHubPullRequest] +} + +"""Arbitrary JSON data.""" +scalar JSON + +""" +An integration entry returned in a list scoped to an app or environment. +""" +type IntegrationListItem { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """The visibility setting for the integration.""" + visibility: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """Whether the integration is a must-use integration.""" + must_use: Boolean +} + +"""An integration entry returned in a list scoped to an organization.""" +type IntegrationClientListItem { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """Whether the integration has active applications.""" + has_active_apps: Boolean + + """The visibility setting for the integration.""" + visibility: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """Whether the integration is a must-use integration.""" + must_use: Boolean +} + +""" +A list of integrations. Note: `nodes` contain `IntegrationListItem`, not `Integration`. +""" +type IntegrationList { + """The total number of matching integrations.""" + total: Int! + + """ + The integrations returned in the list as lightweight `IntegrationListItem` objects. + """ + nodes: [IntegrationListItem!]! +} + +"""A list of organization-scoped integrations.""" +type IntegrationClientList { + """The total number of matching integrations.""" + total: Int! + + """The integrations returned in the list.""" + nodes: [IntegrationClientListItem!]! +} + +"""An integration with configuration and related resources.""" +type Integration { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """The integration configuration.""" + config: JSON + + """The network sites related to the integration.""" + network_sites( + """The maximum number of network sites to return.""" + limit: Int + + """The page number to return.""" + page: Int + + """A status filter for network sites.""" + status: String + + """A search string to filter network sites.""" + search: String + ): NetworkSitesResult + + """The applications related to the integration.""" + applications( + """The maximum number of applications to return.""" + limit: Int + + """The page number to return.""" + page: Int + ): ApplicationsResult + + """A single network site result related to the integration.""" + network_site: NetworkSiteResult + + """The application ID associated with the integration.""" + appId: Int + + """The environment ID associated with the integration.""" + envId: Int + + """The organization ID associated with the integration.""" + orgId: Int +} + +"""A result set of network sites for an integration.""" +type NetworkSitesResult { + """The network sites returned in the result.""" + items: [InflatedNetworkSite] + + """The total number of matching network sites.""" + total: Int + + """The related blueprint, if any.""" + blueprint: Blueprint +} + +"""A single network site result.""" +type NetworkSiteResult { + """The site URL.""" + url: String + + """The site home URL.""" + home_url: String +} + +"""A result set of applications for an integration.""" +type ApplicationsResult { + """The applications returned in the result.""" + items: [InflatedApplication] + + """The total number of matching applications.""" + total: Int +} + +"""Blueprint information related to an integration.""" +type Blueprint { + """The current blueprint status.""" + status: String + + """The blueprint configuration.""" + config: JSON + + """Whether a fresh blueprint is required.""" + requires_fresh_blueprint: Boolean +} + +"""A network site inflated with integration data.""" +type InflatedNetworkSite { + """The network site identifier.""" + id: String + + """The site URL.""" + url: String + + """The site home URL.""" + home_url: String + + """The integration status for the site.""" + status: String + + """The integration configuration for the site.""" + config: JSON +} + +"""An application inflated with integration data.""" +type InflatedApplication { + """The application identifier.""" + id: String + + """The application name.""" + name: String + + """Whether the application is multisite.""" + is_multisite: Boolean + + """The environments on the application.""" + environments: [Environment] +} + +"""A minimal environment reference for integration responses.""" +type Environment { + """The environment identifier.""" + id: Int + + """The environment name.""" + name: String +} + +"""Development environment configuration for integrations.""" +type IntegrationDevEnvConfig { + """The integration configuration data.""" + data: JSON +} + +"""Input for retrieving a specific integration.""" +input GetIntegrationInput { + """The integration slug.""" + slug: String! + + """The network site ID to scope the integration to.""" + networkSiteId: Int + + """The inflate mode to use for the response.""" + inflate: String +} + +""" +Input for managing an integration. Scope rules: provide either `organizationId`, or the pair `appId` + `environmentId` (optionally with `networkId`). +""" +input ManageIntegrationInput { + """ + The application ID for environment-scoped management. Must be provided together with `environmentId`, and must not be combined with `organizationId`. + """ + appId: Int + + """ + The environment ID for environment-scoped management. Must be provided together with `appId`, and must not be combined with `organizationId`. + """ + environmentId: Int + + """ + The organization ID for organization-scoped management. Must not be combined with `appId`, `environmentId`, or `networkId`. + """ + organizationId: Int + + """ + The network site ID for network-site scoped integration changes within an environment scope. + """ + networkId: Int + + """The integration slug.""" + slug: String! + + """The desired integration status.""" + status: String! + + """The integration configuration to apply.""" + config: JSON + + """ + Whether to apply the change to child environments when using app/environment scope. + """ + applyToChildEnvironments: Boolean +} + +"""An integration available in the Integration Center.""" +type IntegrationCenter implements Model { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The display title of the integration.""" + title: String! + + """The serialized metadata for the integration.""" + meta: String! + + """The serialized block configuration for the integration.""" + blocks: String! + + """The visibility setting for the integration.""" + visibility: String! + + """The capabilities or dependencies required by the integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """The site types that can use this integration.""" + allowedSiteTypes: [Int!]! +} + +"""A paginated list of Integration Center entries.""" +type IntegrationCenterList implements ModelList { + """The total number of matching integrations.""" + total: Int + + """The cursor for the next page of integrations.""" + nextCursor: String + + """The integrations returned in the current page.""" + nodes: [IntegrationCenter] + + """A legacy alias for `nodes`.""" + edges: [IntegrationCenter] +} + +"""An Integration Center category.""" +type IntegrationCenterCategory { + """The category slug.""" + slug: String! + + """The display name of the category.""" + name: String! +} + +"""A list of Integration Center categories.""" +type IntegrationCenterCategoryList { + """The total number of categories returned.""" + total: Int + + """The categories returned in the list.""" + nodes: [IntegrationCenterCategory] +} + +"""An invitation to join an organization.""" +type Invitation implements Model { + """The unique identifier for the invitation.""" + id: Int + + """The user who sent the invitation.""" + invitingUser: User + + """The organization the invitation belongs to.""" + organization: Organization + + """The email address the invitation was sent to.""" + emailAddress: String + + """The permissions granted by the invitation.""" + grantedPermissions: InvitationPermissions + + """The current invitation status.""" + status: String + + """When the invitation was created.""" + createdAt: String + + """When the invitation expires.""" + expiresAt: String + + """When the invitation was accepted.""" + acceptedAt: String + + """Whether the invitation can be resent.""" + isResendable: Boolean + + """Whether the invitation can be cancelled.""" + isCancelable: Boolean +} + +"""A paginated list of invitations.""" +type InvitationList { + """The total number of matching invitations.""" + total: Int + + """The cursor for the next page of invitations.""" + nextCursor: String + + """The invitations returned in the current page.""" + nodes: [Invitation] +} + +"""The permissions granted by an invitation.""" +type InvitationPermissions { + """The organization role granted by the invitation.""" + organizationRoleId: String + + """The application roles granted by the invitation.""" + applicationRoles: [InvitationPermissionsApplicationRole] +} + +"""An application role granted by an invitation.""" +type InvitationPermissionsApplicationRole { + """The application ID the role applies to.""" + appId: Int + + """The application the role applies to.""" + app: App + + """The application role ID granted by the invitation.""" + roleId: ApplicationRoleId + + """The application role granted by the invitation.""" + role: ApplicationRole +} + +"""Input for creating invitations.""" +input CreateInvitationInput { + """The organization ID to invite users into.""" + organizationId: Int! + + """The email addresses to invite.""" + emailAddresses: [String]! + + """The permissions to grant to invited users.""" + grantedPermissions: InvitationPermissionsInput! +} + +"""Input describing the permissions granted by an invitation.""" +input InvitationPermissionsInput { + """The organization role to grant.""" + organizationRoleId: OrgRoleId + + """The application roles to grant.""" + applicationRoles: [InvitationPermissionsApplicationRoleInput] +} + +"""An application role to grant within an invitation.""" +input InvitationPermissionsApplicationRoleInput { + """The application ID the role applies to.""" + appId: Int + + """The application role ID to grant.""" + roleId: ApplicationRoleId +} + +"""The result of creating invitations.""" +type CreateInvitationPayload { + """The invitations that were created.""" + invitations: [Invitation] +} + +"""Input for accepting an invitation.""" +input AcceptInvitationInput { + """The invitation code to accept.""" + invitationCode: String +} + +"""The result of accepting an invitation.""" +type AcceptInvitationPayload { + """The resulting invitation status.""" + status: String +} + +"""Input for resending an invitation.""" +input ResendInvitationInput { + """The invitation ID to resend.""" + invitationId: Int +} + +"""The result of resending an invitation.""" +type ResendInvitationPayload { + """The invitation that was resent.""" + invitation: Invitation +} + +"""Input for cancelling an invitation.""" +input CancelInvitationInput { + """The invitation ID to cancel.""" + invitationId: Int +} + +"""The result of cancelling an invitation.""" +type CancelInvitationPayload { + """The invitation that was cancelled.""" + invitation: Invitation +} + +"""A background job.""" +type Job implements JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] +} + +"""Progress details for a job.""" +type JobProgress { + """The current status of the job.""" + status: String + + """The individual progress steps for the job.""" + steps: [JobProgressStep] +} + +"""A single progress step within a job.""" +type JobProgressStep { + """The display name of the step.""" + name: String + + """The step key.""" + step: String + + """The unique identifier for the step.""" + id: String + + """The current status of the step.""" + status: String +} + +"""Common fields shared by all job types.""" +interface JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] +} + +"""A metadata entry attached to a job.""" +type JobMetadata { + """The metadata key.""" + name: String + + """The metadata value.""" + value: String +} + +"""A job that switches an environment's primary domain.""" +type PrimaryDomainSwitchJob implements JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] + + """The domain being set as primary.""" + newDomain: Domain +} + +"""Media Import Configuration""" +type MediaImportConfig { + """Allowed File Types""" + allowedFileTypes: MediaImportAllowedFileTypes + + """Allowed File Size Limit""" + fileSizeLimitInBytes: BigInt + + """Allowed File Name Length""" + fileNameCharCount: Int +} + +"""A detected anomaly in an environment metric.""" +type MetricAnomaly { + """The unique identifier for the anomaly.""" + id: Int! + + """When the anomaly started.""" + startTime: String! + + """When the anomaly ended.""" + endTime: String + + """The metric value at the start of the anomaly.""" + startValue: Float + + """The metric value at the end of the anomaly.""" + endValue: Float + + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """The custom metric threshold configuration ID, if any.""" + customMetricThresholdsConfigId: Int +} + +"""A list of anomalies returned for a metric query.""" +type MetricAnomaliesList { + """The query identifier for the anomalies request.""" + queryId: String! + + """The metric name queried.""" + metricName: String! + + """The site ID the anomalies belong to.""" + siteId: Int! + + """The environment ID the anomalies belong to.""" + environmentId: Int! + + """The total number of anomalies returned.""" + totalAnomalies: Int! + + """The anomalies returned for the query.""" + anomalies: [MetricAnomaly]! +} + +"""A table row used in anomaly context details.""" +type AnomalyContextTable { + """The item label.""" + item: String! + + """The count for the item.""" + count: Int! +} + +"""Context data associated with a metric anomaly.""" +interface AnomalyContextData { + """The context data type.""" + type: String +} + +"""Context data for a 429 anomaly.""" +type Anomaly429ContextData implements AnomalyContextData { + """The context data type.""" + type: String + + """The total number of requests in the anomaly window.""" + totalRequests: Int! + + """The top hosts contributing to the anomaly.""" + topHosts: [AnomalyContextTable]! + + """The top country codes contributing to the anomaly.""" + topCountryCodes: [AnomalyContextTable]! + + """The top user agents contributing to the anomaly.""" + topUserAgents: [AnomalyContextTable]! + + """The top remote addresses contributing to the anomaly.""" + topRemoteAddr: [AnomalyContextTable]! +} + +"""Detailed context for a metric anomaly.""" +type MetricAnomalyContext { + """The anomaly identifier.""" + id: Int! + + """When the anomaly started.""" + startTime: String + + """When the anomaly ended.""" + endTime: String + + """The metric value at the start of the anomaly.""" + startValue: Float + + """The metric value at the end of the anomaly.""" + endValue: Float + + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """The contextual data attached to the anomaly.""" + data: AnomalyContextData +} + +"""A single threshold rule for a metric.""" +input MetricThresholdInput { + """The threshold value.""" + value: Float! + + """The comparison operator for the threshold.""" + operator: String! +} + +"""Input for setting or updating metric thresholds.""" +input SetOrUpdateMetricThresholdsInput { + """The environment ID the thresholds apply to.""" + envId: Int! + + """The metric name the thresholds apply to.""" + metricName: String! + + """The threshold rules to set.""" + thresholds: [MetricThresholdInput]! +} + +"""Input for deleting metric thresholds.""" +input DeleteMetricThresholdsInput { + """The environment ID the thresholds apply to.""" + envId: Int! + + """The metric name the thresholds apply to.""" + metricName: String! + + """The event type whose thresholds should be deleted.""" + eventType: String! +} + +"""A metric threshold configured for an environment.""" +type MetricThreshold { + """The unique identifier for the threshold.""" + id: Int! + + """The threshold value.""" + value: Float! + + """The comparison operator for the threshold.""" + operator: String! + + """The metric name the threshold applies to.""" + metricName: String! +} + +"""The result of deleting metric thresholds.""" +type DeleteMetricThresholdsPayload { + """Whether the delete operation succeeded.""" + success: Boolean! +} + +"""The result of setting or updating metric thresholds.""" +type SetOrUpdateMetricThresholdPayload { + """Whether the set or update operation succeeded.""" + success: Boolean! + + """The thresholds after the operation.""" + thresholds: [MetricThreshold] +} + +"""Pageview totals for an application or organization.""" +type Pageviews { + """The total number of pageviews.""" + total: BigInt + + """The number of static asset requests.""" + staticRequests: BigInt + + """The number of application requests.""" + appRequests: BigInt + + """The number of API requests.""" + apiRequests: BigInt + + """The start date for the pageview range.""" + startDate: String + + """The end date for the pageview range.""" + endDate: String + + """The pageview breakdown details.""" + details: [PageviewDetails] +} + +"""Pageview totals for a single time slice.""" +type PageviewDetails { + """The total number of pageviews in the slice.""" + total: Int + + """The number of static asset requests in the slice.""" + staticRequests: Int + + """The number of application requests in the slice.""" + appRequests: Int + + """The number of API requests in the slice.""" + apiRequests: Int + + """The start date for the slice.""" + startDate: String + + """The end date for the slice.""" + endDate: String +} + +"""A single metric measurement.""" +type MetricMeasurement { + """The timestamp for the measurement.""" + timestamp: String! + + """The measured value.""" + value: Float + + """The baseline value used for comparison.""" + baseline: Float + + """Whether the measurement is anomalous.""" + isAnomalous: Boolean + + """ + The breakdown bucket this measurement belongs to when the upstream aggregation groups by a dimension (e.g. 'human', 'ai_agent', 'crawler'). Null when the query was not grouped by a dimension. + """ + breakdown: String +} + +""" +A health score summary describing the qualitative state of an environment. +""" +type HealthScore { + """Numeric score (0–100). Higher is healthier.""" + score: Int + + """Human-readable explanation of why the environment received this score.""" + description: String +} + +""" +An overview of insights and metrics for an environment over a date range. Inner sections are returned as JSON to allow upstream evolution without schema churn. +""" +type EnvironmentInsightsOverview { + """ + Qualitative insight entries (positive/action items grouped by category). Each item is an object with at least `type`, `category`, `title`, and `description`. + """ + insights: JSON + + """ + Aggregated metric summaries with current vs previous period totals. Each item is an object with at least `metric`, `currTotalAggr`, `prevTotalAggr`, `aggrFunction`, and `measurementUnit`. + """ + metrics: JSON + + """Overall health score for the environment over the selected window.""" + healthScore: HealthScore +} + +"""Aggregated measurements for a metric query.""" +type AggregatedMetricMeasurements { + """The query identifier for the metric request.""" + queryId: String! + + """The metric name.""" + metricName: String! + + """The display name of the metric.""" + metricDisplayName: String + + """The resolution of the aggregated measurements.""" + resolution: Int + + """The aggregated total for the current period.""" + currTotalAggr: Float + + """The aggregated total for the previous period.""" + prevTotalAggr: Float + + """The aggregation function applied to the metric.""" + aggrFunction: String + + """The unit of measurement.""" + measurementUnit: String + + """The measurements returned for the query.""" + measurements: [MetricMeasurement]! +} + +"""Input for disabling New Relic on an environment.""" +input AppEnvironmentDisableNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to disable New Relic on.""" + environmentId: Int! +} + +"""The result of disabling New Relic.""" +type AppEnvironmentDisableNewRelicPayload { + """Whether New Relic was disabled successfully.""" + success: Boolean! +} + +"""Input for enabling New Relic on an environment.""" +input AppEnvironmentEnableNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to enable New Relic on.""" + environmentId: Int! +} + +"""The result of enabling New Relic.""" +type AppEnvironmentEnableNewRelicPayload { + """Whether New Relic was enabled successfully.""" + success: Boolean! +} + +"""Input for adding a New Relic user to an environment.""" +input AppEnvironmentAddNewRelicUserInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to add the user to.""" + environmentId: Int! + + """The first name of the user to add.""" + firstName: String! + + """The last name of the user to add.""" + lastName: String! + + """The email address of the user to add.""" + email: String! +} + +"""The result of adding a New Relic user.""" +type AppEnvironmentAddNewRelicUserPayload { + """Whether the user was added successfully.""" + success: Boolean! +} + +"""Input for deleting a New Relic user from an environment.""" +input AppEnvironmentDeleteNewRelicUserInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to remove the user from.""" + environmentId: Int! + + """The New Relic user ID to remove.""" + userId: Int! +} + +"""The result of deleting a New Relic user.""" +type AppEnvironmentDeleteNewRelicUserPayload { + """Whether the user was deleted successfully.""" + success: Boolean! +} + +"""Input for listing New Relic configuration on an environment.""" +input AppEnvironmentListNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to inspect.""" + environmentId: Int! +} + +"""A New Relic user.""" +interface NewRelicUser { + """The unique identifier for the user.""" + id: Int! + + """The email address of the user.""" + email: String! + + """The display name of the user.""" + name: String! +} + +"""A list of New Relic users.""" +type NewRelicUserList { + """The total number of users.""" + total: BigInt! + + """The users returned in the current page.""" + nodes: [NewRelicUser]! + + """The cursor for the next page of users.""" + nextCursor: String +} + +"""Input for deleting a notification subscription.""" +input DeleteNotificationSubscriptionInput { + """The notification subscription ID to delete.""" + notificationSubscriptionId: Int! +} + +"""The result of deleting a notification subscription.""" +type DeleteNotificationSubscriptionPayload { + """Whether the notification subscription was deleted.""" + deleted: Boolean +} + +"""Supported webhook payload versions.""" +enum NotificationWebhookVersion { + """The legacy webhook payload format.""" + v0 + + """The current webhook payload format.""" + v1 +} + +"""Supported notification recipient channels.""" +enum NotificationRecipientType { + """Deliver notifications by email.""" + EMAIL + + """Deliver notifications to a Slack webhook.""" + SLACK + + """Deliver notifications to a generic webhook.""" + WEBHOOK + + """Deliver notifications to a Google Chat webhook.""" + GOOGLE_CHAT + + """Deliver notifications to a Microsoft Teams webhook.""" + MICROSOFT_TEAMS +} + +"""Additional configuration for a notification recipient.""" +input NotificationRecipientMetaInput { + """The webhook payload version to send for webhook recipients.""" + webhookVersion: NotificationWebhookVersion +} + +"""A notification recipient that can receive subscribed notifications.""" +interface NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""Webhook-specific metadata for a notification recipient.""" +type WebhookRecipientMeta { + """The webhook payload version configured for the recipient.""" + webhookVersion: String + + """The last response body returned by the webhook endpoint.""" + lastResponse: String + + """The last HTTP status code returned by the webhook endpoint.""" + lastResponseCode: Int + + """When the webhook endpoint last responded.""" + lastResponseTime: Date +} + +"""A webhook-based notification recipient.""" +type WebhookNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """Webhook-specific metadata for the recipient.""" + meta: WebhookRecipientMeta + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Slack webhook notification recipient.""" +type SlackNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Slack webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Google Chat webhook notification recipient.""" +type GoogleChatNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Google Chat webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Microsoft Teams webhook notification recipient.""" +type MicrosoftTeamsNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Microsoft Teams webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""An email notification recipient.""" +type EmailNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The email address that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A paginated list of notification recipients.""" +type NotificationRecipientList { + """The total number of recipients in the result set.""" + total: BigInt! + + """The recipients in the current page.""" + nodes: [NotificationRecipient]! + + """The cursor for the next page of recipients.""" + nextCursor: String +} + +"""Input for creating a notification recipient.""" +input AddNotificationRecipientInput { + """The organization that will own the recipient.""" + organizationId: BigInt! + + """ + The application ID used to scope access checks when creating the recipient. + """ + appId: BigInt + + """The display name for the recipient.""" + name: String! + + """Notes describing the recipient.""" + description: String + + """Additional configuration for the recipient.""" + meta: NotificationRecipientMetaInput + + """The delivery channel to configure.""" + recipientType: NotificationRecipientType! + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String! +} + +"""The result of creating a notification recipient.""" +type AddNotificationRecipientPayload { + """The created notification recipient.""" + notificationRecipient: NotificationRecipient +} + +"""Input for updating a notification recipient.""" +input UpdateNotificationRecipientInput { + """The recipient ID to update.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Whether the recipient should be marked active.""" + active: Boolean + + """Notes describing the recipient.""" + description: String + + """Additional configuration for the recipient.""" + meta: NotificationRecipientMetaInput + + """The display name for the recipient.""" + name: String + + """The delivery channel to configure.""" + recipientType: NotificationRecipientType + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String +} + +"""The result of updating a notification recipient.""" +type UpdateNotificationRecipientPayload { + """The updated notification recipient.""" + notificationRecipient: NotificationRecipient +} + +"""Additional metadata for a notification subscription.""" +type NotificationSubscriptionMeta { + """The event types that trigger the subscription.""" + eventTypes: [String!] +} + +"""Input for notification subscription metadata.""" +input NotificationSubscriptionMetaInput { + """The event types that trigger the subscription.""" + eventTypes: [String!] +} + +"""A notification subscription that links a recipient to a target entity.""" +type NotificationSubscription { + """The unique identifier for the subscription.""" + id: Int! + + """Whether the subscription is active.""" + active: Boolean + + """Notes describing the subscription.""" + description: String + + """The entity type the subscription applies to.""" + entityType: String! + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String! + + """ + The application associated with the subscription target, when available. + """ + application: App + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMeta + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The recipient that receives notifications for this subscription.""" + notificationRecipient: NotificationRecipient + + """When the subscription was created.""" + createdAt: Date + + """When the subscription was last updated.""" + updatedAt: Date +} + +"""A paginated list of notification subscriptions.""" +type NotificationSubscriptionList { + """The total number of subscriptions in the result set.""" + total: BigInt! + + """The subscriptions in the current page.""" + nodes: [NotificationSubscription]! + + """The cursor for the next page of subscriptions.""" + nextCursor: String +} + +"""Input for deleting a notification recipient.""" +input DeleteNotificationRecipientInput { + """The organization that owns the recipient.""" + organizationId: Int! + + """The notification recipient ID to delete.""" + notificationRecipientId: Int! +} + +"""The result of deleting a notification recipient.""" +type DeleteNotificationRecipientPayload { + """Whether the notification recipient was deleted.""" + deleted: Boolean +} + +"""Input for creating a notification subscription.""" +input AddNotificationSubscriptionInput { + """The recipient that should receive notifications.""" + notificationRecipientId: BigInt! + + """The organization that owns the recipient and subscription.""" + organizationId: BigInt! + + """Notes describing the subscription.""" + description: String! + + """Whether the subscription should be active.""" + active: Boolean + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMetaInput + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The entity type the subscription applies to.""" + entityType: String! + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String! +} + +"""The result of creating a notification subscription.""" +type AddNotificationSubscriptionPayload { + """The created notification subscription.""" + notificationSubscription: NotificationSubscription +} + +"""Input for updating a notification subscription.""" +input UpdateNotificationSubscriptionInput { + """The subscription ID to update.""" + notificationSubscriptionId: Int! + + """The recipient that should receive notifications after the update.""" + notificationRecipientId: Int + + """Notes describing the subscription.""" + description: String + + """Whether the subscription should be active.""" + active: Boolean + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMetaInput + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The entity type the subscription applies to.""" + entityType: String + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String +} + +"""The result of updating a notification subscription.""" +type UpdateNotificationSubscriptionPayload { + """The updated notification subscription.""" + notificationSubscription: NotificationSubscription +} + +"""Input for sending a test notification to a recipient.""" +input SendTestNotificationInput { + """The recipient that should receive the test notification.""" + notificationRecipientId: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """The optional header to include in the test notification.""" + header: String + + """The optional body to include in the test notification.""" + body: String +} + +"""The result of sending a test notification.""" +type SendTestNotificationPayload { + """Whether the test notification was sent successfully.""" + sent: Boolean +} + +"""Authentication methods that can be used to access VIP.""" +enum UserAuthMethod { + """Sign in with WordPress.com SSO.""" + wpcom + + """Sign in with GitHub SSO.""" + github + + """Sign in with a non-organization SSO provider.""" + other_sso + + """Sign in with an organization-managed identity provider.""" + organization_sso + + """Access restricted by organization SSO enforcement.""" + restricted +} + +""" +An organization that owns applications and users in WordPress VIP. This is the primary entry point for organization-scoped app, user, and event traversal. +""" +type Organization implements Model { + """The unique identifier for the organization.""" + id: Int + + """The display name of the organization.""" + name: String + + """The Salesforce account identifier for the organization.""" + salesforceId: String + + """The URL-friendly slug for the organization.""" + slug: String + + """Whether the organization is part of a FedRAMP environment.""" + isFedramp: Boolean + + """ + Whether the organization has a signed BAA and must follow HIPAA requirements. + """ + isHipaa: Boolean + + """The current service status for the organization.""" + serviceStatus: String + + """ + The applications that belong to the organization. Returns an AppList with `total`, `nextCursor`, and `nodes`. + """ + apps( + """The maximum number of applications to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter applications by active state.""" + active: String + + """The free-text filter to match against applications.""" + matching: String + + """Filter applications by application type IDs.""" + appType: [Int] + + """The page number to fetch.""" + page: Int + ): AppList + + """The VIP support package assigned to the organization.""" + supportPackage: String + + """ + Whether Let's Encrypt certificates are disallowed for the organization. + """ + letsEncryptDisallowed: Boolean + + """The inactivity threshold, in days, used for organization users.""" + considerUsersInactiveAfterDays: Int + + """Whether organization SSO access enforcement is enabled.""" + enforceSSOAccess: Boolean + + """The traffic unit used for organization limits and reporting.""" + trafficType: TrafficType + + """The traffic allocation or limit for the organization.""" + traffic: Int + + """The organization contacts grouped by role.""" + contacts: OrganizationContacts + + """The invitations sent for the organization.""" + invitations( + """The maximum number of invitations to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter invitations by status.""" + status: String + + """The free-text filter to match against invitations.""" + matching: String + ): InvitationList + + """The notification recipients configured for the organization.""" + notificationRecipients( + """The application ID used to scope app-role permission checks.""" + appId: Int + + """The maximum number of recipients to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against recipients.""" + matching: String + ): NotificationRecipientList + + """The notification subscriptions configured for the organization.""" + notificationSubscriptions( + """The maximum number of subscriptions to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter subscriptions by active status.""" + active: Boolean + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + + """Filter subscriptions by their VIN flag.""" + vin: Boolean + ): NotificationSubscriptionList + + """A single notification subscription on the organization.""" + notificationSubscription( + """The notification subscription ID.""" + id: Int! + ): NotificationSubscription + + """The Salesforce plan associated with the organization.""" + plan: OrganizationPlan + + """The Salesforce subscriptions matching a supported product code filter.""" + subscriptions( + """ + The Salesforce subscription code to query, such as `ADDINSTALL` or `BASENONPRODENV`. + """ + search: String! + ): [SalesforceSubscription] + + """Pageview metrics for the organization.""" + pageviews: Pageviews + + """Request statistics for the organization.""" + requestStats( + """The start of the reporting window.""" + from: Date + + """The end of the reporting window.""" + to: Date + ): OrgRequestStatsList + + """Visitor statistics for the organization.""" + visitorsStats( + """The number of days to include in the reporting window.""" + days: Int + + """The start of the reporting window.""" + from: Date + + """The end of the reporting window.""" + to: Date + ): VisitorsStatsList + + """ + The users that belong to the organization. Supports both cursor pagination (`after`) and a legacy page-number argument (`page`). + """ + users( + """Filter users by VIP status.""" + isVIP: Boolean + + """Filter users by ID.""" + id: Int + + """The maximum number of users to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter for external users.""" + externalUsers: Boolean + + """Filter users by authentication method.""" + authMethod: UserAuthMethod + ): UserList + + """The identity providers configured for the organization.""" + identityProviders( + """The identity provider ID to filter by.""" + id: Int + ): IdentityProviderList + + """ + The audit events recorded for the organization. Returns an AuditEventList with cursor pagination metadata. + """ + events( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of events to return.""" + first: Int + + """The sort order to apply.""" + order: String + + """Whether to exclude anomaly events from the results.""" + excludeAnomalyEvents: Boolean + ): AuditEventList + + """Permission checks for the current user on this organization.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """The auth domains configured for the organization.""" + authDomains( + """The exact auth domain to filter by.""" + domain: String + ): OrganizationAuthDomainList + + """The organization-level feature flags.""" + features: [OrganizationFeature] + + """The integrations configured for the organization.""" + integrations: IntegrationClientList + + """A single integration configured for the organization.""" + integration( + """The integration slug.""" + slug: String! + ): Integration +} + +"""An organization-level feature flag.""" +type OrganizationFeature { + """The feature flag slug.""" + slug: String + + """Whether the feature is enabled.""" + enabled: Boolean +} + +"""The traffic units used for organization plans and reporting.""" +enum TrafficType { + """Traffic measured in monthly unique visitors.""" + MUV + + """Traffic measured in HTTP requests.""" + HTTP +} + +"""The Salesforce-backed plan details for an organization.""" +type OrganizationPlan { + """The plan name.""" + planName: String + + """The plan start date.""" + planStartDate: String + + """The plan end date.""" + planEndDate: String + + """The number of requests included in the plan.""" + planIncludedRequests: Int + + """The support ticket SLA for the plan.""" + ticketSLA: String + + """The uptime SLA for the plan.""" + uptimeSLA: String + + """The number of applications allowed by the plan.""" + numberOfAllowedApplications: Int + + """The number of non-production environments allowed by the plan.""" + numberOfAllowedNonProdEnvironments: Int + + """The code review service level for the plan.""" + codeReviewLevel: String + + """The add-ons included with the plan.""" + addOns: [String] + + """The traffic unit used by the plan.""" + trafficType: TrafficType + + """The traffic allocation included in the plan.""" + traffic: Int +} + +"""A Salesforce subscription associated with an organization.""" +type SalesforceSubscription { + """The Salesforce product code.""" + productCode: String + + """The Salesforce product family.""" + productFamily: String + + """The Salesforce product name.""" + productName: String + + """The Salesforce product type.""" + productType: String + + """The subscribed quantity.""" + quantity: Int + + """The subscription start date.""" + startDate: String + + """The subscription end date.""" + endDate: String + + """The related application ID, when applicable.""" + applicationId: Int +} + +"""The primary contact groups for an organization.""" +type OrganizationContacts { + """The account owner contacts.""" + accountOwners: OrganizationContactList + + """The support contacts.""" + supportContacts: OrganizationContactList + + """The technical contacts.""" + technicalContacts: OrganizationContactList + + """The VIP relationship manager contact.""" + vipRelationshipManager: OrganizationContact + + """The VIP technical account manager contact.""" + vipTechnicalAccountManager: OrganizationContact + + """The VIP launch TAM contact.""" + vipLaunchTAM: OrganizationContact +} + +"""A list of organization contacts.""" +type OrganizationContactList { + """The total number of contacts in the list.""" + total: Int + + """The contacts in the list.""" + nodes: [OrganizationContact] +} + +"""A contact associated with an organization.""" +type OrganizationContact { + """The contact name.""" + name: String + + """The contact job title.""" + title: String + + """The contact type.""" + type: String + + """The contact email address.""" + email: String +} + +"""A paginated list of organizations.""" +type OrgList implements ModelList { + """The total number of matching organizations.""" + total: Int + + """The cursor for the next page of organizations.""" + nextCursor: String + + """The organizations returned in the current page.""" + nodes: [Organization] + + """A legacy alias for `nodes`.""" + edges: [Organization] +} + +"""The result of checking a permission for the current user.""" +type PermissionResult { + """The permission key that was evaluated.""" + permission: String + + """Whether the permission is allowed.""" + isAllowed: Boolean +} + +""" +Input for generating a Google Sheets access token from service account credentials. +""" +input GenerateGoogleSheetsAccessTokenInput { + """ + The Google service account credentials to exchange for an access token. + """ + credentials: GoogleSheetsCredentialsInput! +} + +"""Google service account credentials for accessing Google Sheets.""" +input GoogleSheetsCredentialsInput { + """The credential type.""" + type: String! + + """The Google Cloud project ID.""" + project_id: String! + + """The private key ID.""" + private_key_id: String! + + """The private key.""" + private_key: String! + + """The service account email.""" + client_email: String! + + """The service account client ID.""" + client_id: String! + + """The OAuth authorization URI.""" + auth_uri: String! + + """The OAuth token URI.""" + token_uri: String! + + """The auth provider certificate URL.""" + auth_provider_x509_cert_url: String! + + """The client certificate URL.""" + client_x509_cert_url: String! + + """The Google API universe domain.""" + universe_domain: String! +} + +"""The result of generating a Google Sheets access token.""" +type GenerateGoogleSheetsAccessTokenPayload { + """The generated Google access token.""" + accessToken: String! + + """When the access token expires.""" + expiresAt: BigInt +} + +"""A source code repository connected to WordPress VIP.""" +type Repo implements Model { + """The unique identifier for the repository record.""" + id: Int + + """The repository name in `owner/name` format.""" + name: String + + """The default or selected branch for the repository.""" + branch: String + + """The applications linked to this repository.""" + apps: AppList +} + +"""A review queue of repositories that need attention.""" +type ReviewQueue { + """The repositories currently in the review queue.""" + repos: [Repo] +} + +"""Input for rolling an environment back to a previous deployment.""" +input RollbackInput { + """The application ID that owns the environment.""" + appId: Int + + """The environment ID to roll back.""" + environmentId: Int + + """The deployment ID to roll back to.""" + toDeploymentId: Int +} + +"""The result of a rollback request.""" +type RollbackPayload { + """The deployment created by the rollback.""" + newDeployment: Deployment +} + +"""Certificate signing request information.""" +input CSRInfo { + """The certificate common name.""" + commonName: String! + + """The alternative names to include in the certificate.""" + altNames: [String] + + """The country code for the certificate subject.""" + country: String! + + """The state or region for the certificate subject.""" + state: String! + + """The locality or city for the certificate subject.""" + locality: String! + + """The organization for the certificate subject.""" + organization: String! + + """The organizational unit for the certificate subject.""" + organizationUnit: String + + """The email address for the certificate subject.""" + emailAddress: String +} + +"""Input for creating a CSR.""" +input CreateCSRInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The CSR details to generate.""" + csr: CSRInfo! +} + +"""The result of creating a CSR.""" +type CreateCSRPayload { + """The generated certificate ID.""" + certificateId: Int +} + +"""Input for adding a certificate.""" +input AddCertificateInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The CSR string.""" + csr: String! + + """The private key for the certificate.""" + key: String! + + """The certificate body.""" + certificate: String! + + """The trusted certificate chain.""" + trustedCertificate: String +} + +"""The result of adding a certificate.""" +type AddCertificatePayload { + """The created certificate ID.""" + certificateId: Int + + """The created certificate.""" + certificate: String +} + +"""Input for activating a certificate on domains.""" +input ActivateCertificateInput { + """The domain names to activate the certificate on.""" + domainNames: [String] + + """The certificate ID to activate.""" + certificateId: Int +} + +"""The result of activating a certificate.""" +type ActivateCertificatePayload { + """The activated certificate ID.""" + certificateId: Int +} + +"""A decoded certificate signing request.""" +type CSRDecoded { + """The decoded common name.""" + commonName: String + + """The decoded alternative names.""" + altNames: [String] + + """The decoded country code.""" + country: String + + """The decoded state or region.""" + state: String + + """The decoded locality or city.""" + locality: String + + """The decoded organization.""" + organization: String + + """The decoded organizational unit.""" + organizationUnit: String + + """The decoded email address.""" + emailAddress: String +} + +"""Issuer details for a certificate.""" +type CertificateIssuer { + """The issuer country code.""" + country: String + + """The issuer organization.""" + organization: String + + """The issuer common name.""" + commonName: String +} + +"""A TLS certificate.""" +type Certificate { + """The certificate identifier.""" + certificateId: Int + + """Domain name. Ex: www.example.com""" + commonName: String + + """OpenSSL generated CSR string""" + csr: String + + """The decoded CSR details.""" + csrDecoded: CSRDecoded + + """Alternative names""" + san: [String] + + """Whether the certificate is active.""" + active: Boolean + + """Whether a certificate body is present.""" + hasCertificate: Boolean + + """When the certificate validity begins.""" + beginsTimestamp: String + + """When the certificate expires.""" + expiresTimestamp: String + + """When the certificate record was created.""" + created: String + + """The issuer details for the certificate.""" + issuer: CertificateIssuer + + """Whether the certificate is currently valid.""" + valid: Boolean +} + +"""A paginated list of certificates.""" +type CertificateList { + """The total number of matching certificates.""" + total: Int + + """The cursor for the next page of certificates.""" + nextCursor: String + + """The certificates returned in the current page.""" + nodes: [Certificate] +} + +"""Input for updating a certificate.""" +input UpdateCertificateInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The certificate ID to update.""" + certificateId: Int! + + """The replacement certificate body.""" + certificate: String! + + """The replacement trusted certificate chain.""" + trustedCertificate: String +} + +"""The result of updating a certificate.""" +type UpdateCertificatePayload { + """The updated certificate.""" + certificate: Certificate +} + +"""Input for decoding a CSR.""" +input DecodeCSRInput { + """The CSR string to decode.""" + csr: String! +} + +"""Input for deleting a certificate.""" +input DeleteCertificateInput { + """The domain name associated with the certificate.""" + domainName: String! + + """The certificate ID to delete.""" + certificateId: Int! +} + +"""The result of deleting a certificate.""" +type DeleteCertificatePayload { + """Whether the certificate was deleted.""" + deleted: Boolean +} + +"""A purpose-specific token issued to a user.""" +type Token implements Model { + """The unique identifier for the token.""" + id: Int + + """The user ID that owns the token.""" + userId: Int + + """The token expiration time as a Unix timestamp.""" + exp: Int + + """Whether the token is active.""" + active: Boolean + + """Whether the token was disabled due to inactivity.""" + disabledDueToInactivity: Boolean + + """When the token was created.""" + createdAt: Date + + """When the token was last used.""" + lastUsedAt: Date + + """When the token expires.""" + expiresAt: Date + + """The purpose of the token.""" + purpose: String + + """The environment IDs associated with the token.""" + environmentIds: [Int] +} + +"""Input for deactivating a purpose token.""" +input DeactivatePurposeTokenInput { + """The token ID to deactivate.""" + id: Int! + + """The purpose of the token to deactivate.""" + purpose: String! +} + +"""Input for generating an email verification token.""" +input GenerateEmailVerificationTokenInput { + """The email address to verify.""" + email: String! +} + +"""The result of deactivating a purpose token.""" +type DeactivatePurposeTokenPayload { + """Whether the token was deactivated.""" + success: Boolean +} + +"""The result of generating an email verification token.""" +type EmailVerificationTokenPayload { + """Whether the token was generated successfully.""" + success: Boolean + + """When the generated token expires.""" + expiresAt: Date + + """The email address associated with the token.""" + email: String +} + +"""Input for validating an email verification token.""" +input ValidateEmailVerificationTokenInput { + """The email verification token to validate.""" + token: String! +} + +"""The result of validating an email verification token.""" +type ValidateEmailVerificationTokenPayload { + """The email address associated with the token.""" + email: String + + """Whether the token is valid.""" + success: Boolean +} + +"""Input for cancelling a pending email verification token.""" +input CancelEmailVerificationTokenInput { + """The token ID to cancel.""" + id: Int +} + +"""The result of cancelling a pending email verification token.""" +type CancelPendingEmailVerificationTokenPayload { + """Whether a pending token was cancelled.""" + success: Boolean + + """The token that was cancelled.""" + cancelledToken: Token +} + +"""A paginated list of purpose tokens.""" +type TokenList implements ModelList { + """The cursor for the next page of tokens.""" + nextCursor: String + + """The tokens returned in the current page.""" + nodes: [Token!]! + + """The total number of matching tokens.""" + total: Int! +} + +"""A user in WordPress VIP.""" +type User implements Model { + """The unique identifier for the user.""" + id: Int + + """The display name for the user.""" + displayName: String + + """The primary email address for the user.""" + emailAddress: String + + """Whether the user's primary email address is verified.""" + isEmailVerified: Boolean + + """Whether the user still has a legacy unverified email state.""" + isEmailLegacyUnverified: Boolean + + """The user's GitHub username.""" + githubUsername: String + + """The user's WordPress.com username.""" + wpcomUsername: String + + """Whether the user currently has VIP access.""" + isVIP: Boolean + + """The Auth0 identifier for the user.""" + auth0Id: String + + """The VIP Auth identifier for the user.""" + vipAuthId: String + + """Whether the user signs in through VIP Auth.""" + isVipAuthUser: Boolean + + """The configured MFA methods for the user.""" + mfaMethods: MfaMethods + + """The SAML NameID from the user's current SSO identity.""" + samlNameId: String + + """The internal tracking identifier used for analytics and debug tooling.""" + trackingUserId: String + + """The active tokens for the user.""" + tokens: [Token] + + """The organization roles assigned to the user.""" + organizationRoles( + """The organization ID to filter by.""" + organizationId: Int + + """The organization role ID to filter by.""" + roleId: String + ): UserOrganizationRoleList + + """The application roles assigned to the user.""" + applicationRoles( + """The organization ID used to scope application roles.""" + organizationId: Int + + """The application ID to filter by.""" + appId: Int + ): UserApplicationRoleList + + """When the user was last seen in the current organization context.""" + lastSeenAt: Date + + """ + The organization ID associated with the SAML identity provider used for login. + """ + samlOrganizationId: Int + + """The name of the SAML identity provider used for login.""" + samlIdentityProviderName: String + + """The authentication method used for the current session.""" + authMethod: String + + """ + Whether the user is considered inactive in the current organization context. + """ + isConsideredInactive: Boolean + + """The latest email verification token data for the user.""" + emailVerification: EmailVerificationTokenData +} + +"""The currently authenticated user.""" +type Me { + """The unique identifier for the current user.""" + id: Int + + """The display name for the current user.""" + displayName: String + + """The primary email address for the current user.""" + emailAddress: String + + """Whether the current user's primary email address is verified.""" + isEmailVerified: Boolean + + """Whether the current user still has a legacy unverified email state.""" + isEmailLegacyUnverified: Boolean + + """The current user's GitHub username.""" + githubUsername: String + + """The current user's WordPress.com username.""" + wpcomUsername: String + + """Whether the current user currently has VIP access.""" + isVIP: Boolean + + """The Auth0 identifier for the current user.""" + auth0Id: String + + """The VIP Auth identifier for the current user.""" + vipAuthId: String + + """Whether the current user signs in through VIP Auth.""" + isVipAuthUser: Boolean + + """The configured MFA methods for the current user.""" + mfaMethods: MfaMethods + + """The SAML NameID from the current user's SSO identity.""" + samlNameId: String + + """The internal tracking identifier used for analytics and debug tooling.""" + trackingUserId: String + + """The active tokens for the current user.""" + tokens: [Token] + + """The organization roles assigned to the current user.""" + organizationRoles( + """The organization ID to filter by.""" + organizationId: Int + ): UserOrganizationRoleList + + """The application roles assigned to the current user.""" + applicationRoles( + """The organization ID used to scope application roles.""" + organizationId: Int + + """The application ID to filter by.""" + appId: Int + ): UserApplicationRoleList + + """ + When the current user was last seen in the current organization context. + """ + lastSeenAt: Date + + """ + The organization ID associated with the SAML identity provider used for login. + """ + samlOrganizationId: Int + + """The name of the SAML identity provider used for login.""" + samlIdentityProviderName: String + + """The authentication method used for the current session.""" + authMethod: String + + """ + Whether the current user is considered inactive in the current organization context. + """ + isConsideredInactive: Boolean + + """ + Whether the current user would be VIP before proxy-based checks are applied. + """ + shouldBeVIP: Boolean + + """The latest email verification token data for the current user.""" + emailVerification: EmailVerificationTokenData + + """The IP address of the current request.""" + currentIP: String +} + +"""The MFA methods available to a user.""" +type MfaMethods { + """The user's preferred MFA method.""" + preferredMethod: String + + """The MFA methods configured for the user.""" + configuredMethods: [String] +} + +"""Input for generating a user token.""" +input UserTokenGenerationInput { + """The requested token lifetime, up to one year.""" + lifetime: String +} + +"""The result of generating a user token.""" +type UserTokenGenerationPayload { + """The generated JWT.""" + jwt: String +} + +"""Input for creating a user.""" +input CreateUserInput { + """The GitHub username for the user to create.""" + githubUsername: String! + + """Whether the new user should be granted VIP access.""" + isVIP: Boolean +} + +"""The result of creating a user.""" +type CreateUserPayload { + """The created user.""" + user: User +} + +"""Input for updating a user.""" +input UpdateUserInput { + """The user ID to update.""" + userId: Int! + + """The GitHub username to set.""" + githubUsername: String + + """The email address to set.""" + emailAddress: String + + """The display name to set.""" + displayName: String +} + +"""The result of updating a user.""" +type UpdateUserPayload { + """The updated user.""" + user: User +} + +"""Input for updating a user's organization role.""" +input UpdateUserOrganizationRoleInput { + """The user ID to update.""" + userId: Int! + + """The organization ID that owns the role.""" + organizationId: Int! + + """The organization role ID to assign.""" + role: String +} + +"""The result of updating a user's organization role.""" +type UpdateUserOrganizationRolePayload { + """The updated user.""" + user: User + + """The updated organization role.""" + organizationRole: UserOrganizationRole +} + +"""Input for removing a user from an organization.""" +input RemoveUserFromOrganizationInput { + """The user ID to remove.""" + userId: Int! + + """The organization ID to remove the user from.""" + organizationId: Int! +} + +"""The result of removing a user from an organization.""" +type RemoveUserFromOrganizationPayload { + """The affected user.""" + user: User +} + +"""Input for deactivating a user token.""" +input DeactivateUserTokenInput { + """The token ID to deactivate.""" + tokenId: Int! +} + +"""The result of deactivating a user token.""" +type DeactivateUserTokenPayload { + """Whether the token was deactivated successfully.""" + success: Boolean +} + +"""The current status of an email verification token.""" +enum EmailVerificationStatus { + """The token was used to verify the email address.""" + VERIFIED + + """The token was canceled.""" + CANCELED + + """The token expired before it was used.""" + EXPIRED + + """The token is still pending verification.""" + PENDING + + """The email address is unverified.""" + UNVERIFIED + + """The email address is in the legacy unverified state.""" + LEGACY_UNVERIFIED +} + +"""The latest email verification token data for a user.""" +type EmailVerificationTokenData { + """The email address being verified.""" + email: String + + """The current status of the latest verification token.""" + status: EmailVerificationStatus + + """When the latest verification token expires.""" + expires: Date +} + +"""A paginated list of users.""" +type UserList implements ModelList { + """The total number of matching users.""" + total: Int + + """The cursor for the next page of users.""" + nextCursor: String + + """The users returned in the current page.""" + nodes: [User] + + """A legacy alias for `nodes`.""" + edges: [User] +} + +"""An application role assigned to a user.""" +type UserApplicationRole implements Model { + """The unique identifier for the role assignment.""" + id: Int + + """The user ID that holds the role.""" + userId: Int + + """The application ID the role applies to.""" + appId: Int + + """The application the role applies to.""" + app: App + + """The role ID assigned to the user.""" + roleId: ApplicationRoleId + + """The role definition assigned to the user.""" + role: ApplicationRole + + """The source of the role assignment.""" + source: String +} + +"""An application role definition.""" +type ApplicationRole { + """The role name.""" + name: String + + """The parent role this role extends, if any.""" + extends: String +} + +"""The available application role IDs.""" +enum ApplicationRoleId { + """Application administrator.""" + admin + + """Application contributor with write access.""" + write + + """Application viewer with read access.""" + read +} + +"""A paginated list of user application roles.""" +type UserApplicationRoleList implements ModelList { + """The total number of matching role assignments.""" + total: Int + + """The cursor for the next page of role assignments.""" + nextCursor: String + + """The role assignments returned in the current page.""" + nodes: [UserApplicationRole] + + """A legacy alias for `nodes`.""" + edges: [UserApplicationRole] +} + +"""A single application role assignment for a user.""" +input UserApplicationRoleInput { + """The user ID that should receive the role.""" + userId: Int! + + """The application ID the role applies to.""" + appId: Int! + + """The application role ID to assign.""" + roleId: ApplicationRoleId +} + +"""Input for replacing a user's application role assignments.""" +input SetUserApplicationRolesInput { + """The application roles to assign to the user.""" + applicationRoles: [UserApplicationRoleInput]! +} + +"""The result of updating a user's application roles.""" +type SetUserApplicationRolesPayload { + """The application roles after the update.""" + applicationRoles: [UserApplicationRole] +} + +"""An organization role assigned to a user.""" +type UserOrganizationRole implements Model { + """The unique identifier for the role assignment.""" + id: Int + + """The user ID that holds the role.""" + userId: Int + + """The organization the role applies to.""" + organization: Organization + + """The organization ID the role applies to.""" + organizationId: Int + + """The role ID assigned to the user.""" + roleId: OrgRoleId + + """The role definition assigned to the user.""" + role: OrgRole + + """The source of the role assignment.""" + source: String + + """Whether the role assignment is restricted.""" + restricted: Boolean + + """The source or actor that applied the restriction.""" + restrictedBy: String + + """The name of the organization that caused the restriction.""" + restrictedOrgName: String +} + +"""An organization role definition.""" +type OrgRole { + """The role name.""" + name: String + + """The parent role this role extends, if any.""" + extends: String +} + +"""The available organization role IDs.""" +enum OrgRoleId { + """Organization administrator.""" + admin + + """Organization member.""" + member + + """Organization viewer.""" + viewer +} + +"""A paginated list of user organization roles.""" +type UserOrganizationRoleList implements ModelList { + """The total number of matching role assignments.""" + total: Int + + """The cursor for the next page of role assignments.""" + nextCursor: String + + """The role assignments returned in the current page.""" + nodes: [UserOrganizationRole] + + """A legacy alias for `nodes`.""" + edges: [UserOrganizationRole] +} + +"""VIP metadata collected for a pull request.""" +type VIPPRMeta { + """The review comments left on the pull request.""" + reviewComments: [GitHubPullRequestReviewComment] + + """The issue-style comments left on the pull request.""" + comments: [GitHubComment] + + """The formal reviews submitted on the pull request.""" + reviews: [GitHubReview] +} + +"""A 64-bit integer scalar.""" +scalar BigInt + +"""An ISO 8601 date-time scalar.""" +scalar Date + +"""MediaImportAllowedFileTypes scalar type""" +scalar MediaImportAllowedFileTypes + +"""The API audiences a schema field can target.""" +enum ApiAudience { + """Fields intended for people using the API directly.""" + HUMAN + + """Fields intended for AI agents using the API.""" + AGENT + + """Fields intended for internal-only use.""" + INTERNAL +} + +"""The top-level domains used to categorize public API fields.""" +enum ApiDomain { + """Application management fields.""" + APPS + + """Domain and certificate management fields.""" + DOMAINS + + """Organization management fields.""" + ORGANIZATIONS + + """User and identity management fields.""" + USERS + + """Integration and marketplace fields.""" + INTEGRATIONS + + """Security and access control fields.""" + SECURITY + + """Observability, metrics, and logs fields.""" + OBSERVABILITY +} + +"""A model with an integer identifier.""" +interface Model { + """The unique identifier for the model.""" + id: Int +} + +"""A paginated list of models.""" +interface ModelList { + """The models returned in the current page.""" + nodes: [Model] + + """The total number of matching models.""" + total: Int + + """The cursor for the next page of results.""" + nextCursor: String +} + +"""The root query type for the public API.""" +type Query { + """Retrieve a single application.""" + app( + """The application ID.""" + id: Int + ): App + + """Retrieve a paginated list of applications.""" + apps( + """The application IDs to include.""" + ids: [Int] + + """The exact application name to match.""" + name: String + + """The maximum number of applications to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against applications.""" + matching: String + + """Filter applications by application type IDs.""" + appType: [Int] + + """Filter applications by multisite state.""" + isMultisite: Boolean + + """Filter applications by launch state.""" + launched: Boolean + + """The page number to fetch.""" + page: Int + ): AppList + + """Retrieve a single domain by ID or name.""" + domain( + """The domain ID.""" + id: Int + + """The domain name.""" + name: String + ): Domain + + """Retrieve Tollbit verification details for one or more domains.""" + tollbitDomainsVerification( + """The domain names to verify.""" + names: [String!]! + + """The domain names that should bypass cached verification data.""" + forceRefresh: [String!] + ): [TollbitDomainVerificationResult] + + """Retrieve a paginated list of domains.""" + domains( + """The wildcard patterns to filter by.""" + wildcards: [String] + + """The maximum number of domains to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + ): DomainList + + """Retrieve a single organization.""" + organization( + """The organization ID.""" + id: Int + ): Organization + + """Retrieve a paginated list of organizations.""" + organizations( + """The exact organization name to match.""" + name: String + + """The organization ID.""" + id: Int + + """The maximum number of organizations to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against organizations.""" + matching: String + + """Page number to fetch.""" + page: Int + ): OrgList + + """Retrieve a paginated list of integration center entries.""" + integrationCenter( + """The maximum number of integration center entries to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text search term.""" + search: String + + """The integration slug to filter by.""" + slug: String + + """The integration category to filter by.""" + category: String + ): IntegrationCenterList + + """Retrieve the available integration center categories.""" + integrationCenterCategories: IntegrationCenterCategoryList + + """List integrations for an application, environment, or organization.""" + listIntegrations( + """The application ID to list integrations for.""" + applicationId: Int + + """The environment ID to list integrations for.""" + environmentId: Int + + """The organization ID to list integrations for.""" + organizationId: Int + ): IntegrationList + + """Retrieve repository details by name.""" + repo( + """The repository name in `owner/name` format.""" + name: String + ): Repo + + """Retrieve the currently authenticated user.""" + me: Me + + """Retrieve a single user by ID or GitHub username.""" + user( + """The user ID.""" + id: Int + + """The GitHub username.""" + githubUsername: String + ): User + + """Retrieve a paginated list of users.""" + users( + """Filter users by VIP status.""" + isVIP: Boolean + + """Filter users by organization membership.""" + organizationId: Int + + """Filter users by a free-text match.""" + matching: String + + """The maximum number of users to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter for external users.""" + externalUsers: Boolean + + """Filter for users with an organization admin role.""" + hasOrgAdminRole: Boolean + ): UserList + + """Retrieve a certificate for an organization.""" + certificate( + """The organization ID that owns the certificate.""" + clientId: Int + + """The certificate ID.""" + certificateId: Int + ): Certificate + + """Retrieve backup copy records for an environment.""" + dbBackupCopies( + """The environment ID to query.""" + environmentId: Int + + """The backup file names to filter by.""" + fileNames: [String] + ): DBBackupCopyList + + """List tokens for a specific purpose and set of environments.""" + listPurposeTokens( + """The token purpose to filter by.""" + purpose: String! + + """The environment IDs to include.""" + environmentIds: [Int]! + + """The user ID to filter by.""" + userId: Int + ): TokenList + + """Retrieve the current media import configuration.""" + mediaImportConfig: MediaImportConfig + + """ + Check if the site is ready for an Agentforce sync operation. + Verifies that configuration has propagated to the WordPress runtime. + Use this before enabling the sync button in the UI. + """ + agentforcePreflightCheck( + """The application, environment, and optional network site to validate.""" + input: AgentforcePreflightCheckInput! + ): AgentforcePreflightCheckPayload! + + """ + Get the current progress of an Agentforce sync operation. + Use this to poll for progress updates while a sync is running. + """ + agentforceSyncProgress( + """The application, environment, and optional network site to check.""" + input: AgentforceSyncProgressInput! + ): TriggerAgentforceSyncPayload! +} + +"""Agentforce integration for syncing WordPress content to Salesforce""" +type Agentforce { + """Get WordPress categories available for Agentforce sync""" + categories( + """ + Network site ID for multisite - specifies which subsite to list categories from + """ + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") + ): [String!]! +} + +"""Input for Agentforce preflight check""" +input AgentforcePreflightCheckInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to check""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +"""Response payload for Agentforce preflight check""" +type AgentforcePreflightCheckPayload { + """Number of categories configured for sync""" + categoriesCount: Int! + + """Whether the vip_agentforce_should_ingest_post filter is registered""" + filterRegistered: Boolean! + + """Whether ingestion_api_object_name is configured""" + hasApiObject: Boolean! + + """Whether ingestion_api_source_name is configured""" + hasApiSource: Boolean! + + """Whether ingestion_api_token is configured""" + hasApiToken: Boolean! + + """Whether ingestion_api_instance_url is configured""" + hasApiUrl: Boolean! + + """Whether the site is ready for a sync operation""" + ready: Boolean! + + """Whether sync_all_posts is enabled in config""" + syncAllPosts: Boolean! +} + +"""Input for querying Agentforce sync progress""" +input AgentforceSyncProgressInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to query""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +""" +An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +""" +type AppEnvironment { + """Whether the environment is active.""" + active: Boolean + + """The currently active backup operation.""" + activeBackup: Backup + + """Agentforce integration for syncing WordPress content to Salesforce""" + agentforce: Agentforce + + """The allowlisted IP addresses for the environment.""" + allowedIPs: AppEnvironmentIPAllowList + + """Additional context for a specific anomaly.""" + anomalyContext( + """The anomaly ID.""" + anomalyId: Int + ): MetricAnomalyContext + + """The application ID that owns the environment.""" + appId: Int + + """The backup policy ID applied to the environment.""" + backupPolicyId: Int + + """The current V2 backup shipping configuration.""" + backupShippingConfigV2: AppEnvironmentBackupShippingV2 + + """The backups available for the environment.""" + backups( + """The pagination cursor to continue from.""" + after: String + + """The end date for filtering backups.""" + endDate: String + + """The maximum number of backups to return.""" + first: Int + + """The backup ID to retrieve.""" + id: Float + + """The start date for filtering backups.""" + startDate: String + ): BackupsList + + """The SQL dump tool used for backups.""" + backupsSqlDumpTool: String + + """The basic auth configuration for the environment.""" + basicAuth: AppEnvironmentBasicAuth + + """The currently configured branch for the environment.""" + branch: String + + """Available repository branches for the environment.""" + branches( + """The maximum number of branches to return.""" + limit: Int + ): AppEnvironmentBranchesList + + """The build configuration for the environment.""" + buildConfiguration: BuildConfiguration + + """The build history for the environment.""" + builds: BuildList + + """Get codebase related information""" + codebase: CodebaseInfo + + """ + WP-CLI commands executed on the environment. Returns a cursor-based list payload. + """ + commands( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of commands to return.""" + first: Int + + """The sort order to apply.""" + order: String + + """Page number to fetch.""" + page: Int + + """The field to sort by.""" + sort: String + + """Filter commands by status.""" + status: String + ): WPCLICommandList + + """The recent commits relevant to the environment.""" + commits( + """The maximum number of commits to return.""" + first: Int + ): GitCommitList + + """When the environment was created.""" + createdAt: String + + """The current deployed commit SHA.""" + currentCommit: String + + """The custom error page configuration for the environment.""" + customErrorPageConfig: CustomErrorPageConfig + + """The datacenter serving the environment.""" + datacenter: String + + """Database backup copies available for the environment.""" + dbBackupCopies( + """The backup file names to filter by.""" + fileNames: [String] + ): DBBackupCopyList + + """Whether a database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """The default domain assigned to the environment.""" + defaultDomain: String + + """The defensive mode configuration and state.""" + defensiveMode( + """The start date for the defensive mode reporting window.""" + fromDate: Date + + """The end date for the defensive mode reporting window.""" + toDate: Date + ): AppEnvironmentDefensiveMode + + """The deployment strategy configured for the environment.""" + deploymentStrategy: String + + """ + The deployments for the environment. This is the richer deployment view and supports cursor pagination plus a legacy `page` argument. + """ + deployments( + """The maximum number of deployments to return.""" + first: Int + + """The deployment ID to retrieve.""" + id: Int + + """The pagination cursor to continue from.""" + nextCursor: String + + """The page number to fetch.""" + page: Int + ): DeploymentList + + """ + The recent deploy records for the environment. This is a lightweight/legacy view. + """ + deploys( + """The maximum number of deploys to return.""" + first: Int + ): DeployList + + """The domains mapped to the environment.""" + domains( + """The pagination cursor to continue from.""" + after: String + + """Domain names to exclude from the results.""" + exclude: [String] + + """The maximum number of domains to return.""" + first: Int + + """Filter domains by verification status.""" + isVerified: Boolean + + """The free-text filter to match against domains.""" + matching: String + + """Page number to fetch.""" + page: Int + ): DomainList + + """The edge configuration for the environment.""" + edgeConfig: EdgeConfig + + """The WASM edge workers deployed to the environment.""" + edgeWorkers: [EdgeWorker!]! + + """The environment variables configured for the environment.""" + environmentVariables: EnvironmentVariablesList + + """ + The audit events recorded for the environment. Returns an AuditEventList with `total`, `nextCursor`, and `nodes`/`edges`. + """ + events( + """The pagination cursor to continue from.""" + after: String + + """Only include events after this timestamp.""" + afterTs: String + + """Only include events before this timestamp.""" + beforeTs: String + + """Whether to exclude anomaly events.""" + excludeAnomalyEvents: Boolean + + """Whether to exclude WP-CLI events.""" + excludeWPCLI: Boolean + + """The maximum number of events to return.""" + first: Int + + """Filter events by event types.""" + types: String + ): AuditEventList + + """Counts of audit events for the environment.""" + eventsCounts( + """The start of the reporting window.""" + from: String + + """The end of the reporting window.""" + to: String + + """The event types to include.""" + types: [String] + ): [AuditEventCount] + + """The development environment configuration for integrations.""" + getIntegrationsDevEnvConfig: IntegrationDevEnvConfig + + """Health metrics for the environment.""" + health( + """The end of the reporting window.""" + endDate: String + + """The start of the reporting window.""" + startDate: String + ): AppEnvironmentHealth + + """The HSTS settings for the environment.""" + hstsSettings: AppEnvironmentHSTSSettings + + """The icon for the environment.""" + icon( + """The requested icon size.""" + size: Int + ): AppEnvironmentIcon + + """The unique identifier for the environment.""" + id: Int + + """The current import status for the environment.""" + importStatus: AppEnvironmentImportStatus + + """ + An overview of insights and metrics for the environment over a date range. + """ + insightsOverview( + """The start date for the overview window.""" + fromDate: Date! + + """The end date for the overview window.""" + toDate: Date! + ): EnvironmentInsightsOverview + + """A single integration configured for the environment.""" + integration( + """The network site ID for a network-scoped integration.""" + networkSiteId: Int + + """The integration slug.""" + slug: String + ): Integration + + """ + The integrations configured for the environment. Returns an IntegrationList where `nodes` are `IntegrationListItem`. + """ + integrations: IntegrationList + + """The IP addresses assigned to the environment.""" + ips: AppEnvironmentIPs + + """Whether database partitioning is enabled.""" + isDBPartitioningEnabled: Boolean + + """Whether the environment is in a FedRAMP context.""" + isFedramp: Boolean + + """ + Whether the environment belongs to an organization with a signed BAA and must follow HIPAA requirements. + """ + isHipaa: Boolean + + """Whether the environment runs on Kubernetes.""" + isK8sResident: Boolean + + """Whether live backup copies are allowed for the environment.""" + isLiveBackupCopyAllowed: Boolean + + """Whether the environment is a multisite install.""" + isMultisite: Boolean + + """Whether the environment is running the latest deployed code.""" + isOnLatestCode: Boolean + + """Whether the multisite install uses subdirectories.""" + isSubdirectoryMultisite: Boolean + + """Jobs running on or related to the environment.""" + jobs( + """The job types to filter by as enum values.""" + jobTypes: [AppEnvironmentJobType!] + + """The job types to filter by as raw values.""" + types: [String!] + ): [JobInterface] + + """The most recent backup for the environment.""" + latestBackup: Backup + + """The most recent media export for the environment.""" + latestMediaExport: MediaExport + + """When launch mode ends for the environment.""" + launchModeEndAt: String + + """Whether the environment has been launched.""" + launched: Boolean + + """The live backup copies for the environment.""" + liveBackupCopies: [LiveBackupCopy] + + """The current V2 log shipping configuration.""" + logShippingConfig: AppEnvironmentLogShippingV2 + + """ + Application and platform logs for the environment. Use `type: app` or `type: batch`. Returns `pollingDelaySeconds` to guide incremental polling. + """ + logs( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of log entries to return.""" + limit: Int + + """The log stream to retrieve.""" + type: AppEnvironmentLogType + ): AppEnvironmentLogsList + + """Media exports for the environment.""" + mediaExports( + """The pagination cursor to continue from.""" + nextCursor: String + ): MediaExportsList + + """The current media import status for the environment.""" + mediaImportStatus: AppEnvironmentMediaImportStatus + + """Metric anomalies detected for the environment.""" + metricAnomalies( + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """Whether to exclude custom anomalies.""" + excludeCustomAnomalies: Boolean + + """The start date for the anomaly window.""" + fromDate: Date + + """The metric name to retrieve.""" + metricName: String + + """The end date for the anomaly window.""" + toDate: Date + ): MetricAnomaliesList + + """The metric thresholds configured for the environment.""" + metricThresholds( + """The metric name to filter by.""" + metricName: String + ): [MetricThreshold] + + """Aggregated metrics for the environment.""" + metrics( + """Whether to aggregate the metric series.""" + aggregate: Boolean + + """The start date for the metric window.""" + fromDate: Date + + """Whether to include baseline data.""" + includeBaseline: Boolean + + """The metric name to retrieve.""" + metricName: String + + """The end date for the metric window.""" + toDate: Date + ): AggregatedMetricMeasurements + + """The display name of the environment.""" + name: String + + """The New Relic configuration for the environment.""" + newRelic: AppEnvironmentNewRelic + + """The notification subscriptions configured for the environment.""" + notificationSubscriptions( + """Filter subscriptions by active status.""" + active: Boolean + + """The pagination cursor to continue from.""" + after: String + + """The maximum number of subscriptions to return.""" + first: Int + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + ): NotificationSubscriptionList + + """Permission checks for the current user on this environment.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """The phpMyAdmin availability status for the environment.""" + phpMyAdminStatus: PHPMyAdminStatus + + """The primary domain for the environment.""" + primaryDomain: Domain + + """The progress of a primary domain switch.""" + primaryDomainSwitchProgress( + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int + ): AppEnvironmentPrimaryDomainSwitchProgress + + """The repository name for the environment's codebase.""" + repo: String + + """The repository for the environment.""" + repository: GitRepository + + """Request statistics for the environment.""" + requestStats( + """The single date to query.""" + date: String + + """The number of days to include.""" + days: Int + + """The start date for the reporting window.""" + from: String + + """The number of months to include.""" + months: Int + + """The end date for the reporting window.""" + to: String + ): RequestStatsList + + """Database slow query logs for the environment.""" + slowlogs( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of slow log entries to return.""" + limit: Int + ): AppEnvironmentSlowlogsList + + """The software details for the environment.""" + software: AppEnvironmentSoftwareDetails + + """The software settings for the environment.""" + softwareSettings: AppEnvironmentSoftwareSettings + + """A preview of the next environment sync.""" + syncPreview: AppEnvironmentSyncPreview + + """The current sync progress for the environment.""" + syncProgress( + """The sync job ID.""" + sync: Int + ): AppEnvironmentSyncProgress + + """The environment type, such as production or develop.""" + type: String + + """The unique label for the environment.""" + uniqueLabel: String + + """The current subsite domain update status.""" + updateSubsiteDomainStatus: AppEnvironmentUpdateSubsiteDomainStatus + + """Get WordPress Site Installation Details""" + wpInstallation: WPInstallation + + """Get WordPress Site Details""" + wpSites( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of WordPress sites to return.""" + first: Int + ): WPSiteList + + """Get WordPress Site Details from SDS""" + wpSitesSDS( + """The pagination cursor to continue from.""" + after: String + + """The blog ID to filter by.""" + blogId: Int + + """The maximum number of WordPress sites to return.""" + first: Int + + """Filter sites by launch status.""" + launchStatus: WPSiteLaunchStatus + + """The free-text filter to match against sites.""" + matching: String + + """The sort order to apply.""" + order: String + + """Page number to fetch.""" + page: Int + + """The field to sort by.""" + sort: String + ): WPSiteList + + """The strategy used to execute WP-CLI commands.""" + wpcliStrategy: AppEnvironmentWPCliStrategy +} + +"""Mutation request input to abort a Media Import""" +input AppEnvironmentAbortMediaImportInput { + """The unique ID of the Application""" + applicationId: Int! + + """The uniqueID of the Environment""" + environmentId: Int! +} + +"""Response payload for aborting a Media Import""" +type AppEnvironmentAbortMediaImportPayload { + """The unique ID of the Application""" + applicationId: Int + + """The unique ID of the Environment""" + environmentId: Int + + """Media Import Abort Action Response""" + mediaImportStatusChange: AppEnvironmentMediaImportStatusChange +} + +"""Variables for the Activate Let's Encrypt Mutation""" +input AppEnvironmentActivateLetsEncryptOnDomainInput { + """The unique ID for the domain""" + domainId: Int + + """The ID of the environment that this domain belongs to""" + environmentId: Int + + """The unique ID for the domain""" + id: Int + + """Provisions the www-alt domain""" + includeWWW: Boolean = true + + """Overrides the existing certificate (if any) on the domain""" + overrideExisting: Boolean +} + +"""Response from the Activate Let's Encrypt Mutation""" +type AppEnvironmentActivateLetsEncryptOnDomainPayload { + """The domain that Let's Encrypt was activated on""" + domain: Domain +} + +"""Variables for the Add Domain mutation""" +input AppEnvironmentAddDomainInput { + """The domain name (i.e. something like example.com or sub.example.com)""" + domain: NewDomain + + """The ID of the environment that this domain belongs to""" + environmentId: Int + + """Flag to set verification code""" + generateVerificationCode: Boolean + + """The App ID""" + id: Int +} + +"""The result of adding a domain to an environment.""" +type AppEnvironmentAddDomainPayload { + """The added domain.""" + domain: Domain +} + +"""Variables for the AddRequestStats mutation""" +input AppEnvironmentAddRequestStatsInput { + """The application ID""" + applicationId: Int! + + """Date for which we want to sync - if we want to sync only for one day""" + date: String + + """The environment ID where we want to run the command""" + environmentId: Int! + + """Date range for which we want to sync - if we want to sync for a range""" + fromDate: String + + """The end date for the sync range.""" + toDate: String +} + +"""Response payload for Request Stats""" +type AppEnvironmentAddRequestStatsPayload { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the environment""" + environmentId: Int! +} + +"""A lightweight backup summary for an environment.""" +type AppEnvironmentBackup { + """When the backup was created.""" + createdAt: String + + """The backup ID.""" + id: Int + + """The backup size in bytes.""" + size: Int +} + +"""Input for deleting backup shipping configuration.""" +input AppEnvironmentBackupShippingDeleteInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of a backup shipping operation.""" +type AppEnvironmentBackupShippingOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling backup shipping.""" +input AppEnvironmentBackupShippingUpdateStatusInput { + """Whether backup shipping should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The current backup shipping configuration for an environment.""" +type AppEnvironmentBackupShippingV2 { + """The daily hour used for daily schedules.""" + dailyHour: Int + + """Whether backup shipping is enabled.""" + enabled: Boolean! + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzure + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCP + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3 + + """The destination path prefix.""" + path: String + + """The object storage provider receiving the backups.""" + provider: CloudShippingObjectStorageProviders! + + """The backup shipping schedule.""" + schedule: BackupShippingSchedule! +} + +""" +Input for updating V2 backup shipping configuration. `provider` is required and callers should provide the matching provider-specific `object_storage_config_*` block. +""" +input AppEnvironmentBackupShippingV2Input { + """The daily hour used for daily schedules.""" + dailyHour: Int + + """Whether backup shipping is enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The Azure configuration, used when `provider` is `azure_blob_storage`.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput + + """The GCP configuration, used when `provider` is `gcp_cloud_storage`.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The S3 configuration, used when `provider` is `aws_s3`.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The destination path prefix.""" + path: String + + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The backup shipping schedule.""" + schedule: BackupShippingSchedule +} + +"""The basic auth users configured for an environment.""" +type AppEnvironmentBasicAuth { + """The total number of basic auth users.""" + total: Int + + """The basic auth usernames.""" + users: [String] +} + +"""Input for deleting basic auth users.""" +input AppEnvironmentBasicAuthDeleteInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The usernames to delete.""" + username: [String] +} + +"""Input for creating or updating basic auth users.""" +input AppEnvironmentBasicAuthInput { + """The basic auth users to store.""" + basicAuth: [AppEnvironmentBasicAuthUserInput] + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of a basic auth operation.""" +type AppEnvironmentBasicAuthPayload { + """The application that owns the environment.""" + app: App + + """The username affected by the operation.""" + user: String +} + +"""A basic auth user definition.""" +input AppEnvironmentBasicAuthUserInput { + """The basic auth password.""" + password: String + + """The basic auth username.""" + username: String +} + +"""A single repository branch.""" +type AppEnvironmentBranch { + """The branch name.""" + name: String +} + +"""A paginated list of repository branches.""" +type AppEnvironmentBranchesList { + """The cursor for the next page of branches.""" + nextCursor: String + + """The branches returned in the current page.""" + nodes: [AppEnvironmentBranch] + + """The suggested polling delay before fetching branches again.""" + pollingDelaySeconds: Int! + + """The total number of branches.""" + total: BigInt +} + +"""Input for completing an Elasticsearch upgrade.""" +input AppEnvironmentCompleteElasticsearchUpgradeInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""Input for creating a child environment from a production environment""" +input AppEnvironmentCreateChildEnvironmentInput { + """The unique ID of the parent environment""" + appId: Int! + + """The branch to use for the child environment""" + branch: String + + """The name for the new child environment""" + environmentName: String! + + """The Node.js version for the child environment""" + nodejsVersion: String + + """The PHP version for the child environment""" + phpVersion: String +} + +"""Response from creating a child environment""" +type AppEnvironmentCreateChildEnvironmentPayload { + """The unique ID of the newly created child environment""" + environmentId: Int! + + """The name of the newly created child environment""" + environmentName: String! + + """Success message""" + message: String! + + """Whether the operation was successful""" + success: Boolean! +} + +"""Input for deactivating a domain on an environment.""" +input AppEnvironmentDeactivateDomainInput { + """The domain ID to deactivate.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of deactivating a domain on an environment.""" +type AppEnvironmentDeactivateDomainPayload { + """The deactivated domain.""" + domain: Domain +} + +"""The defensive mode state for an environment.""" +type AppEnvironmentDefensiveMode { + """The current defensive mode configuration.""" + config: AppEnvironmentDefensiveModeConfig! +} + +"""Stored and effective defensive mode configuration.""" +type AppEnvironmentDefensiveModeConfig { + """The effective configuration after defaults are applied.""" + effective: AppEnvironmentDefensiveModeConfigObject! + + """The configuration explicitly stored for the environment.""" + stored: AppEnvironmentDefensiveModeConfigObject +} + +"""Input for updating defensive mode configuration.""" +input AppEnvironmentDefensiveModeConfigInput { + """The challenge type to apply.""" + challengeType: Int! + + """The absolute connection threshold that triggers defensive mode.""" + connectionThresholdAbsolute: Int + + """The connection threshold percentage that triggers defensive mode.""" + connectionThresholdPercentage: Int + + """Whether defensive mode should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""A defensive mode configuration object.""" +type AppEnvironmentDefensiveModeConfigObject { + """The challenge type applied while defensive mode is enabled.""" + challengeType: Int + + """The absolute connection threshold that triggers defensive mode.""" + connectionThresholdAbsolute: Int + + """The connection threshold percentage that triggers defensive mode.""" + connectionThresholdPercentage: Int + + """When defensive mode should automatically disable, as a Unix timestamp.""" + disableAtEpoch: Int + + """Whether defensive mode is enabled.""" + enabled: Boolean + + """ + How long to keep defensive mode enabled after traffic drops below threshold. + """ + keepEnabledUnderThresholdForSeconds: Int + + """The maximum request rate allowed.""" + maxRequestRate: Int + + """The priority bypass value.""" + priorityBypass: Int +} + +"""The result of a defensive mode operation.""" +type AppEnvironmentDefensiveModeOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling defensive mode.""" +input AppEnvironmentDefensiveModeUpdateStatusInput { + """Whether defensive mode should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of an Elasticsearch upgrade operation.""" +type AppEnvironmentElasticsearchUpgradePayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling launch mode on an environment.""" +input AppEnvironmentEnableLaunchModeInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """When launch mode should end.""" + launchModeEndAt: String +} + +"""The result of enabling launch mode.""" +type AppEnvironmentEnableLaunchModePayload { + """The application that owns the environment.""" + app: App + + """The updated environment.""" + environment: AppEnvironment +} + +"""Input for enqueueing an Elasticsearch upgrade.""" +input AppEnvironmentEnqueueElasticsearchUpgradeInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The target Elasticsearch version.""" + version: String +} + +"""Input for generating a database backup copy download URL.""" +input AppEnvironmentGenerateDBBackupCopyUrlInput { + """The backup ID to generate a URL for.""" + backupId: Float + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of generating a database backup copy download URL.""" +type AppEnvironmentGenerateDBBackupCopyUrlPayload { + """The application that owns the environment.""" + app: App + + """Whether the operation succeeded.""" + success: Boolean + + """The generated download URL.""" + url: String +} + +"""Input for generating a signed URL for a media export.""" +input AppEnvironmentGenerateMediaExportSignedUrlInput { + """The application ID that owns the environment.""" + appId: Int + + """The archive file index to fetch, if applicable.""" + archiveFileIndex: Int + + """The environment ID the export belongs to.""" + environmentId: Int + + """The media export ID to generate a URL for.""" + mediaExportId: Float + + """The export target to generate a URL for.""" + target: AppEnvironmentGenerateMediaExportSignedUrlTarget +} + +"""The result of generating a signed URL for a media export.""" +type AppEnvironmentGenerateMediaExportSignedUrlPayload { + """Whether the signed URL was generated successfully.""" + success: Boolean + + """The generated signed URL.""" + url: String +} + +"""The available signed URL targets for a media export.""" +enum AppEnvironmentGenerateMediaExportSignedUrlTarget { + """The export report file.""" + report + + """The exported media archive.""" + media +} + +"""A generic software version entry for an application environment.""" +type AppEnvironmentGenericSoftware implements AppEnvironmentSoftware { + """The version currently installed.""" + version: String! +} + +"""Details about the environment's HSTS settings""" +type AppEnvironmentHSTSSettings { + """Whether HSTS is enabled for an App Environment""" + enabled: Boolean + + """Whether the header includes the includesSubdomains directive""" + includeSubdomains: Boolean + + """The value of the max-age directive""" + maxAge: Int + + """Whether the header includes the preload directive""" + preload: Boolean + + """Whether the App Environment enforces HTTPS everywhere""" + sslEverywhere: Boolean +} + +"""Variables for the UpdateHSTSSettings mutation""" +input AppEnvironmentHSTSSettingsInput { + """The unique ID of the Environment""" + environmentId: Int! + + """The unique ID of the Application""" + id: Int! + + """Whether the header should include the includesSubdomains directive""" + includeSubdomains: Boolean + + """The value of the max-age directive""" + maxAge: Int + + """Whether the header should include the preload directive""" + preload: Boolean +} + +"""Response payload for HSTS Settings updates""" +type AppEnvironmentHSTSSettingsPayload { + """The Application that was updated""" + app: App + + """The response message from GOOP""" + message: String + + """Whether the update was successful""" + success: Boolean +} + +"""Health metrics for an environment.""" +type AppEnvironmentHealth { + """Cache hit totals over time.""" + cacheHit: AppEnvironmentHealthCacheList + + """Cache miss totals over time.""" + cacheMiss: AppEnvironmentHealthCacheList + + """HTTP response code totals over time.""" + responseCodes: AppEnvironmentHealthList +} + +"""Aggregated cache metrics.""" +type AppEnvironmentHealthCacheList { + """The cache metrics grouped by time window.""" + nodes: [AppEnvironmentHealthCacheNodes] + + """The total number of cache events recorded.""" + total: BigInt +} + +"""Cache metrics for a single time window.""" +type AppEnvironmentHealthCacheNodes { + """The start of the time window.""" + from: String + + """The end of the time window.""" + to: String + + """The total number of cache events in the time window.""" + total: BigInt +} + +"""Aggregated HTTP response code metrics.""" +type AppEnvironmentHealthList { + """The distinct HTTP response codes returned.""" + codes: [String] + + """The response code metrics grouped by time window.""" + nodes: [AppEnvironmentHealthNodes] + + """The total number of responses recorded.""" + total: BigInt +} + +"""HTTP response code metrics for a single time window.""" +type AppEnvironmentHealthNodes { + """The count of HTTP 200 responses.""" + _200: BigInt + + """The count of HTTP 201 responses.""" + _201: BigInt + + """The count of HTTP 206 responses.""" + _206: BigInt + + """The count of HTTP 301 responses.""" + _301: BigInt + + """The count of HTTP 302 responses.""" + _302: BigInt + + """The count of HTTP 304 responses.""" + _304: BigInt + + """The count of HTTP 400 responses.""" + _400: BigInt + + """The count of HTTP 401 responses.""" + _401: BigInt + + """The count of HTTP 403 responses.""" + _403: BigInt + + """The count of HTTP 404 responses.""" + _404: BigInt + + """The count of HTTP 405 responses.""" + _405: BigInt + + """The count of HTTP 408 responses.""" + _408: BigInt + + """The count of HTTP 412 responses.""" + _412: BigInt + + """The count of HTTP 416 responses.""" + _416: BigInt + + """The count of HTTP 429 responses.""" + _429: BigInt + + """The count of HTTP 499 responses.""" + _499: BigInt + + """The count of HTTP 500 responses.""" + _500: BigInt + + """The count of HTTP 502 responses.""" + _502: BigInt + + """The count of HTTP 503 responses.""" + _503: BigInt + + """The count of HTTP 504 responses.""" + _504: BigInt + + """The start of the time window.""" + from: String + + """The end of the time window.""" + to: String + + """The total number of responses in the time window.""" + total: BigInt +} + +"""The IP allow list for an environment.""" +type AppEnvironmentIPAllowList { + """The allowlisted IPs.""" + ips: [String] + + """The total number of allowlisted IPs.""" + total: Int +} + +"""The IP addresses assigned to an environment.""" +type AppEnvironmentIPs { + """The IPv4 addresses.""" + ipv4: [String] + + """The IPv6 addresses.""" + ipv6: [String] +} + +"""An icon for an environment.""" +type AppEnvironmentIcon { + """The icon height in pixels.""" + height: Int + + """The icon MIME type.""" + type: String + + """The icon URL.""" + url: String + + """The icon width in pixels.""" + width: Int +} + +"""Input for starting an environment import.""" +input AppEnvironmentImportInput { + """The backup basename to import.""" + basename: String + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The expected MD5 checksum.""" + md5: String + + """The search-and-replace rules to apply.""" + searchReplace: [AppEnvironmentImportSearchReplace] + + """Whether to skip creating a backup before import.""" + skipBackup: Boolean + + """Whether to skip maintenance mode during import.""" + skipMaintenanceMode: Boolean + + """The source URL to import from.""" + url: String + + """The request headers to include when fetching the source URL.""" + urlHeaders: [RequestHeader!] +} + +"""The result of starting an environment import.""" +type AppEnvironmentImportPayload { + """The application that owns the environment.""" + app: App + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""A search-and-replace rule applied during import.""" +input AppEnvironmentImportSearchReplace { + """The source string to replace.""" + from: String + + """The replacement string.""" + to: String +} + +"""The current status of an environment import.""" +type AppEnvironmentImportStatus { + """Whether any database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """Whether an import is currently in progress.""" + importInProgress: Boolean + + """Detailed progress information for the import.""" + progress: AppEnvironmentStatusProgress +} + +"""The job types supported for environments.""" +enum AppEnvironmentJobType { + """Switch the primary domain.""" + set_primary_domain + + """Import a SQL database.""" + sql_import + + """Create a database backup copy.""" + db_backup_copy + + """Update a multisite subsite domain.""" + update_subsite_domain + + """Upgrade the PHP version.""" + upgrade_php + + """Upgrade the WordPress version.""" + upgrade_wordpress + + """Upgrade the MU plugins version.""" + upgrade_muplugins + + """Upgrade the Node.js version.""" + upgrade_nodejs + + """Run a database backup.""" + db_backup +} + +"""Input for marking an application as launched.""" +input AppEnvironmentLaunchedInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of marking an application as launched.""" +type AppEnvironmentLaunchedPayload { + """The application that owns the environment.""" + app: App + + """The updated environment.""" + environment: AppEnvironment +} + +"""Input for generating a live backup copy download URL.""" +input AppEnvironmentLiveBackupCopyDownloadURLInput { + """The live backup copy ID.""" + copyId: String! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of generating a live backup copy download URL.""" +type AppEnvironmentLiveBackupCopyDownloadURLPayload { + """Whether the live backup copy is still processing.""" + processing: Boolean! + + """The size of the downloadable copy in bytes.""" + size: BigInt + + """Whether the operation succeeded.""" + success: Boolean! + + """The generated download URL.""" + url: String +} + +"""A single environment log entry.""" +type AppEnvironmentLog { + """The log message.""" + message: String + + """When the log entry was recorded.""" + timestamp: String +} + +"""Input for deleting log shipping configuration.""" +input AppEnvironmentLogShippingDeleteInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of a log shipping operation.""" +type AppEnvironmentLogShippingOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling log shipping.""" +input AppEnvironmentLogShippingUpdateStatusInput { + """Whether log shipping should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The current log shipping configuration for an environment.""" +type AppEnvironmentLogShippingV2 { + """Whether log shipping is enabled.""" + enabled: Boolean! + + """When shipping last failed.""" + last_failed_shipping_time: String + + """The most recent shipping error message.""" + last_shipping_error_message: String + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzure + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCP + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3 + + """The destination path prefix.""" + path: String + + """The object storage provider receiving the logs.""" + provider: CloudShippingObjectStorageProviders! + + """The log streams being shipped.""" + type: [CloudShippingLogsType!]! +} + +""" +Input for updating V2 log shipping configuration. `provider` is required and callers should provide the matching provider-specific `object_storage_config_*` block. +""" +input AppEnvironmentLogShippingV2Input { + """Whether log shipping is enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The Azure configuration, used when `provider` is `azure_blob_storage`.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput + + """The GCP configuration, used when `provider` is `gcp_cloud_storage`.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The S3 configuration, used when `provider` is `aws_s3`.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The destination path prefix.""" + path: String + + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The log streams to ship.""" + type: [CloudShippingLogsType!]! +} + +"""The available environment log streams.""" +enum AppEnvironmentLogType { + """Application logs (`type: app`).""" + app + + """Batch job logs (`type: batch`).""" + batch +} + +"""A paginated list of environment log entries.""" +type AppEnvironmentLogsList { + """The cursor for the next page of log entries.""" + nextCursor: String + + """The log entries returned in the current page.""" + nodes: [AppEnvironmentLog] + + """The suggested polling delay before fetching logs again.""" + pollingDelaySeconds: Int! + + """The total number of log entries.""" + total: BigInt +} + +"""Response payload for starting and fetching a Media Import""" +type AppEnvironmentMediaImportPayload { + """The unique ID of the Application""" + applicationId: Int + + """The unique ID of the Environment""" + environmentId: Int + + """Media Import Status""" + mediaImportStatus: AppEnvironmentMediaImportStatus! +} + +"""Current status of a Media Import""" +type AppEnvironmentMediaImportStatus { + """Media Import failure details""" + failureDetails: AppEnvironmentMediaImportStatusFailureDetails + + """URL to download the media import error log""" + failureDetailsUrl: String + + """Total number of media files that were imported""" + filesProcessed: Int + + """Total number of media files that are to be import""" + filesTotal: Int + + """Unique Identifier for a Media Import""" + importId: Int + + """Alias of environmentId""" + siteId: Int + + """The actual status of the Media Import""" + status: String +} + +""" +Response payload for executing a status change action on a Media Import +""" +type AppEnvironmentMediaImportStatusChange { + """Unique Identifier for a Media Import""" + importId: Int + + """Alias of environmentId""" + siteId: Int + + """The status of Media Import prior to status change action""" + statusFrom: String + + """The status of Media Import after the status change action""" + statusTo: String +} + +"""Media Import Failure details""" +type AppEnvironmentMediaImportStatusFailureDetails { + """List of errors per file""" + fileErrors: [AppEnvironmentMediaImportStatusFailureDetailsFileErrors] + + """URL to download the media import error log""" + fileErrorsUrl: String + + """List of global errors per import""" + globalErrors: [String] + + """Status of the Media Import prior to failing""" + previousStatus: String +} + +"""Media Import File Errors""" +type AppEnvironmentMediaImportStatusFailureDetailsFileErrors { + """List of Errors per file""" + errors: [String] + + """File Name""" + fileName: String +} + +"""New Relic configuration and status for an environment.""" +type AppEnvironmentNewRelic { + """Whether the current user can manage New Relic users.""" + canManageUsers: Boolean + + """The New Relic dashboard URL.""" + dashboardUrl: String + + """When New Relic is scheduled for deactivation.""" + deactivationTimestamp: String + + """Whether New Relic is enabled.""" + enabled: Boolean + + """Whether New Relic setup has been completed.""" + isSetupComplete: Boolean + + """The sampling percentage configured for New Relic.""" + samplingPercentage: BigInt + + """The New Relic users associated with the environment.""" + users: AppEnvironmentNewRelicUsersList +} + +"""A New Relic user associated with an environment.""" +type AppEnvironmentNewRelicUser { + """The email address of the New Relic user.""" + email: String + + """The New Relic user ID.""" + id: BigInt + + """The display name of the New Relic user.""" + name: String +} + +"""A paginated list of New Relic users.""" +type AppEnvironmentNewRelicUsersList { + """The cursor for the next page of New Relic users.""" + nextCursor: String + + """The New Relic users returned in the current page.""" + nodes: [AppEnvironmentNewRelicUser] + + """The total number of New Relic users.""" + total: BigInt +} + +"""Input for switching an environment's primary domain.""" +input AppEnvironmentPrimaryDomainSwitchInput { + """The domain ID to promote to primary.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of starting a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchPayload { + """The application that owns the environment.""" + app: App + + """The target domain.""" + domain: Domain + + """The updated environment.""" + environment: AppEnvironment + + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int +} + +"""Progress details for a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchProgress { + """The destination domain name.""" + destinationDomain: String + + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int + + """The source domain name.""" + sourceDomain: String + + """The overall status of the switch.""" + status: String + + """The individual steps in the switch.""" + steps: [AppEnvironmentPrimaryDomainSwitchProgressStep] +} + +"""A single step in a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchProgressStep { + """The display name of the step.""" + name: String + + """The step status.""" + status: String + + """The step identifier.""" + step: String +} + +"""Input for retiring an environment.""" +input AppEnvironmentRetireInput { + """The unique ID of the Environment""" + environmentId: Int! + + """The unique ID of the Application""" + id: Int! +} + +"""The result of retiring an environment.""" +type AppEnvironmentRetirePayload { + """The response message from GOOP""" + message: String + + """Whether the retirement was successful""" + success: Boolean +} + +"""A single slow query log entry.""" +type AppEnvironmentSlowlog { + """The SQL query text.""" + query: String + + """How long the query took to execute.""" + queryTime: String + + """The request URI associated with the slow query.""" + requestUri: String + + """The number of rows examined by the query.""" + rowsExamined: String + + """The number of rows returned by the query.""" + rowsSent: String + + """When the slow query was recorded.""" + timestamp: String +} + +"""A paginated list of slow log entries.""" +type AppEnvironmentSlowlogsList { + """The cursor for the next page of slow log entries.""" + nextCursor: String + + """The slow log entries returned in the current page.""" + nodes: [AppEnvironmentSlowlog] + + """The suggested polling delay before fetching slow logs again.""" + pollingDelaySeconds: Int! + + """The total number of slow log entries.""" + total: BigInt +} + +"""A software component installed on an application environment.""" +interface AppEnvironmentSoftware { + """The version currently installed.""" + version: String! +} + +"""Installed software versions for an application environment.""" +type AppEnvironmentSoftwareDetails { + """The installed Node.js version.""" + nodejs: AppEnvironmentGenericSoftware + + """The installed PHP version.""" + php: AppEnvironmentGenericSoftware + + """The installed WordPress version.""" + wordpress: AppEnvironmentGenericSoftware +} + +"""Available software settings for an application environment.""" +type AppEnvironmentSoftwareSettings { + """The mu-plugins software settings.""" + muplugins: AppEnvironmentSoftwareSettingsSoftware + + """The Node.js software settings.""" + nodejs: AppEnvironmentSoftwareSettingsSoftware + + """The PHP software settings.""" + php: AppEnvironmentSoftwareSettingsSoftware + + """The WordPress software settings.""" + wordpress: AppEnvironmentSoftwareSettingsSoftware +} + +"""Variables for the UpdateSoftwareSettings mutation""" +input AppEnvironmentSoftwareSettingsInput { + """The unique ID of the Application""" + appId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """The name of the software being updated""" + softwareName: String! + + """The version the software is being updated to""" + softwareVersion: String! +} + +"""Software settings and available versions for one software package.""" +type AppEnvironmentSoftwareSettingsSoftware { + """The currently selected version.""" + current: AppEnvironmentSoftwareSettingsVersion! + + """The display name of the software.""" + name: String! + + """The available version options.""" + options: [AppEnvironmentSoftwareSettingsVersion!]! + + """Whether the software version is pinned.""" + pinned: Boolean! + + """The internal slug of the software.""" + slug: String! +} + +"""A software version option available for an environment.""" +type AppEnvironmentSoftwareSettingsVersion { + """Whether this version is compatible with the environment.""" + compatible: Boolean! + + """Whether this is the default version.""" + default: Boolean! + + """Whether this version is deprecated.""" + deprecated: Boolean! + + """The latest available release for this software.""" + latestRelease: String! + + """Whether this version is private.""" + private: Boolean! + + """Whether this version is unstable.""" + unstable: Boolean! + + """The version identifier.""" + version: String! +} + +"""Input for starting a database backup copy.""" +input AppEnvironmentStartDBBackupCopyInput { + """The backup ID to copy.""" + backupId: Float + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The subsite ID to target, when applicable.""" + subsiteId: Int + + """The tables to include in the copy.""" + tables: [String] +} + +"""The result of starting a database backup copy.""" +type AppEnvironmentStartDBBackupCopyPayload { + """The application that owns the environment.""" + app: App + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""The result of starting a live backup copy.""" +type AppEnvironmentStartLiveBackupCopyPayload { + """The live backup copy ID.""" + copyId: String + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""Mutation request input to start a Media Import""" +input AppEnvironmentStartMediaImportInput { + """API version to be used for the media import""" + apiVersion: String + + """The unique ID of the Application""" + applicationId: Int! + + """ + Publicly accessible URL that contains an archive of the media files to be imported + """ + archiveUrl: String! + + """The uniqueID of the Environment""" + environmentId: Int! + + """Whether to import intermediate images or not""" + importIntermediateImages: Boolean + + """Whether to overwrite existing files or not""" + overwriteExistingFiles: Boolean +} + +"""Progress details for an environment operation.""" +type AppEnvironmentStatusProgress { + """When the operation finished, as a Unix timestamp.""" + finished_at: Int + + """When the operation started, as a Unix timestamp.""" + started_at: Int + + """The steps completed by the operation.""" + steps: [AppEnvironmentStatusProgressStep] +} + +"""A single step in an environment progress flow.""" +type AppEnvironmentStatusProgressStep { + """When the step finished, as a Unix timestamp.""" + finished_at: Int + + """The display name of the step.""" + name: String + + """The output lines produced by the step.""" + output: [String] + + """The result of the step.""" + result: String + + """When the step started, as a Unix timestamp.""" + started_at: Int +} + +"""The sync configuration preview for an environment.""" +type AppEnvironmentSyncConfig { + """The config files involved in the sync.""" + files: [AppEnvironmentSyncConfigFile] + + """The generated `settings.yml` contents.""" + settingsYml: String +} + +"""A config file included in an environment sync preview.""" +type AppEnvironmentSyncConfigFile { + """The API URL for the file.""" + apiUrl: String + + """The branch containing the file.""" + branch: String + + """The file contents.""" + contents: String + + """The file name.""" + filename: String + + """The HTML URL for the file.""" + htmlUrl: String + + """The repository containing the file.""" + repo: String +} + +"""A sync validation error.""" +type AppEnvironmentSyncError { + """The machine-readable error code.""" + code: String + + """The error message.""" + message: String +} + +"""Input for triggering an environment sync.""" +input AppEnvironmentSyncInput { + """The copy configuration payload.""" + config: JSON + + """The environment ID to sync.""" + environmentId: Int! + + """The source environment ID to sync from.""" + fromEnvironmentId: Int + + """The application ID.""" + id: Int! +} + +"""The result of triggering an environment sync.""" +type AppEnvironmentSyncPayload { + """The application that owns the environment.""" + app: App + + """The environment being synced.""" + environment: AppEnvironment +} + +"""A preview of whether an environment can be synced.""" +type AppEnvironmentSyncPreview { + """The backup that will be used for sync.""" + backup: AppEnvironmentBackup + + """Whether the environment can be synced.""" + canSync: Boolean + + """The configuration preview for the sync.""" + config: AppEnvironmentSyncConfig + + """The validation errors preventing sync.""" + errors: [AppEnvironmentSyncError] + + """The source environment reference.""" + from: AppEnvironment + + """The URL used to create sync file configuration in GitHub.""" + githubCreateSyncFileConfigURL: String + + """The replacements that will be applied during sync.""" + replacements: [AppEnvironmentSyncReplacement] + + """The source environment to sync from.""" + sourceEnvironment: AppEnvironment + + """The destination environment reference.""" + to: AppEnvironment +} + +"""Progress details for an environment sync.""" +type AppEnvironmentSyncProgress { + """When the sync finished, as a Unix timestamp.""" + finished_at: Int + + """When the sync started, as a Unix timestamp.""" + started_at: Int + + """The overall sync status.""" + status: String + + """The individual sync steps.""" + steps: [AppEnvironmentSyncStep] + + """The sync job ID.""" + sync: Int +} + +"""A string replacement that will be applied during sync.""" +type AppEnvironmentSyncReplacement { + """The source value.""" + from: String + + """The replacement value.""" + to: String +} + +"""A single step in an environment sync.""" +type AppEnvironmentSyncStep { + """The display name of the step.""" + name: String + + """The step status.""" + status: String + + """The step identifier.""" + step: String +} + +"""Input for triggering a database backup.""" +input AppEnvironmentTriggerDBBackupInput { + """Whether to perform a dry run.""" + dryRun: Boolean + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of triggering a database backup.""" +type AppEnvironmentTriggerDBBackupPayload { + """Whether the operation succeeded.""" + success: Boolean +} + +"""Variables for the Run WP-CLI Command mutation""" +input AppEnvironmentTriggerWPCLICommandInput { + """The command we want to run. Note: should not include 'wp'""" + command: String + + """The environment ID where we want to run the command""" + environmentId: Int + + """The application ID""" + id: Int +} + +"""Response from the Run WP-CLI Command mutation""" +type AppEnvironmentTriggerWPCLICommandPayload { + """The command that was executed""" + command: WPCLICommand + + """The token for authenticating the socket connection""" + inputToken: String + + """The SSH credentials for connecting to the command session.""" + sshAuthentication: WPCliSSHAuthentication +} + +"""Input for updating a multisite subsite domain.""" +input AppEnvironmentUpdateSubsiteDomainInput { + """The domain ID to assign.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The subsite ID to update.""" + subsiteId: Int + + """The subsite path to update.""" + subsitePath: String +} + +"""The result of updating a subsite domain.""" +type AppEnvironmentUpdateSubsiteDomainPayload { + """The application that owns the environment.""" + app: App + + """The domain assigned to the subsite.""" + domain: Domain + + """The updated environment.""" + environment: AppEnvironment +} + +"""The current status of a subsite domain update.""" +type AppEnvironmentUpdateSubsiteDomainStatus { + """Whether a database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """Detailed progress information for the update.""" + progress: AppEnvironmentStatusProgress + + """Whether a subsite domain update is currently in progress.""" + updateSubsiteDomainInProgress: Boolean +} + +"""The strategies available for running WP-CLI commands.""" +enum AppEnvironmentWPCliStrategy { + """Run WP-CLI over SSH.""" + ssh + + """Run WP-CLI over a websocket connection.""" + websocket +} + +"""A backup available for an environment.""" +type Backup { + """When the backup was created.""" + createdAt: String + + """The partitioning dataset associated with the backup, if any.""" + dataset: DBPartitioningDataset + + """The environment ID the backup belongs to.""" + environmentId: Int + + """The backup filename.""" + filename: String + + """The unique identifier for the backup.""" + id: Float + + """The backup size in bytes.""" + size: Float + + """The SQL dump tool used to generate the backup.""" + sqlDumpTool: String + + """The backup type.""" + type: String +} + +"""The available backup shipping schedules.""" +enum BackupShippingSchedule { + """Ship backups once per day.""" + Daily + + """Ship backups once per hour.""" + Hourly +} + +"""A paginated list of backups.""" +type BackupsList { + """The cursor for the next page of backups.""" + nextCursor: String + + """The backups returned in the current page.""" + nodes: [Backup] + + """The total number of matching backups.""" + total: Int +} + +"""Build configuration for the environment""" +type BuildConfiguration { + """Build type""" + buildType: String! + + """Node.js build environment variables""" + nodeBuildDockerEnv: String! + + """Node.js version""" + nodeJSVersion: String! + + """npm token""" + npmToken: String +} + +"""Variables for the Cancel WP-CLI Command mutation""" +input CancelWPCLICommandInput { + """The unique ID for the running command""" + guid: String +} + +"""Response from the Cancel WP-CLI Command mutation""" +type CancelWPCLICommandPayload { + """The command that was cancelled""" + command: WPCLICommand +} + +"""The log streams available for cloud shipping.""" +enum CloudShippingLogsType { + """Edge logs.""" + edge + + """Origin PHP-FPM logs.""" + origin_php_fpm + + """Origin slow query logs.""" + origin_slowlog + + """Origin Nginx logs.""" + origin_nginx + + """Origin log2logstash logs.""" + origin_log2logstash + + """Origin WP-Cron runner logs.""" + origin_wp_cron_runner + + """Origin Node.js logs.""" + origin_nodejs +} + +"""Azure Blob Storage configuration.""" +type CloudShippingObjectStorageConfigAzure { + """The Azure storage account name.""" + azure_account: String! + + """The Azure container name.""" + azure_container: String! + + """The Azure SAS token.""" + azure_sas_token: String! +} + +"""Azure Blob Storage input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigAzureInput { + """The Azure storage account name.""" + azure_account: String! + + """The Azure container name.""" + azure_container: String! + + """The Azure SAS token.""" + azure_sas_token: String! +} + +"""Google Cloud Storage configuration.""" +type CloudShippingObjectStorageConfigGCP { + """The GCP bucket name.""" + gcp_bucket: String! + + """The GCP credentials JSON.""" + gcp_credentials_json: String! +} + +"""Google Cloud Storage input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigGCPInput { + """The GCP bucket name.""" + gcp_bucket: String! + + """The GCP credentials JSON.""" + gcp_credentials_json: String! +} + +"""Amazon S3 object storage configuration.""" +type CloudShippingObjectStorageConfigS3 { + """The AWS account ID.""" + aws_account_id: String + + """The S3 bucket name.""" + s3_bucket: String! + + """The S3 region.""" + s3_region: String! + + """The IAM role used for shipping.""" + s3_shipper_role: String +} + +"""Amazon S3 input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigS3Input { + """The AWS account ID.""" + aws_account_id: String + + """The S3 bucket name.""" + s3_bucket: String! + + """The S3 region.""" + s3_region: String! + + """The IAM role used for shipping.""" + s3_shipper_role: String +} + +"""The object storage providers supported for cloud shipping.""" +enum CloudShippingObjectStorageProviders { + """Amazon S3.""" + aws_s3 + + """Google Cloud Storage.""" + gcp_cloud_storage + + """Azure Blob Storage.""" + azure_blob_storage +} + +"""Variables for the CodebaseChangeRepo mutation""" +input CodebaseChangeRepoInput { + """The unique ID of the Application""" + appId: Int! + + """The new branch name""" + branch: String! + + """The unique ID of the Environment""" + environmentId: Int! +} + +"""The result of a repository change request.""" +type CodebaseChangeRepoResult { + """A machine-readable result code for the repository change.""" + code: String + + """A human-readable message about the repository change.""" + message: String! + + """Whether the repository change succeeded.""" + success: Boolean! +} + +"""Codebase information for an environment.""" +type CodebaseInfo { + """Plugin maintenance details for the codebase.""" + plugins: CodebasePlugins! +} + +"""Codebase plugin maintenance information for an environment.""" +type CodebasePlugins { + """The pull requests created for plugin updates.""" + pullRequests: [CodebasePullRequest!]! + + """The maintenance tasks associated with plugin updates.""" + tasks: [CodebaseTask!]! + + """The vulnerabilities detected in plugins.""" + vulnerabilities: [CodebaseVulnerability!]! +} + +"""A pull request associated with a codebase update.""" +type CodebasePullRequest { + """The URL for the pull request.""" + link: String! + + """The plugin or module path being updated.""" + modulePath: String! + + """The target version in the pull request.""" + version: String! +} + +"""A task associated with a codebase update.""" +type CodebaseTask { + """When the task was last updated.""" + dateUpdated: String! + + """The reason the task failed, if any.""" + failureReason: String! + + """The plugin or module path the task applies to.""" + modulePath: String! + + """The current task status.""" + status: String! +} + +"""Variables for the CodebaseUpdatePlugin mutation""" +input CodebaseUpdatePluginInput { + """The unique ID of the Application""" + appId: Int! + + """The download link for the new plugin version""" + download: String + + """The unique ID of the Environment""" + environmentId: Int! + + """The location of the plugin in the codebase""" + location: String + + """The marketplace the plugin belongs too""" + marketplace: String + + """The name of the plugin""" + name: String + + """The plugin slug""" + slug: String! + + """The new version to update the plugin""" + version: String + + """The number of active vulns on the plugin""" + vulnCount: Int +} + +"""The result of a plugin update request.""" +type CodebaseUpdatePluginResult { + """The result code for the plugin update request.""" + code: String! + + """A human-readable message about the plugin update request.""" + message: String! + + """The status of the plugin update request.""" + status: String! +} + +"""A vulnerability found in the application codebase.""" +type CodebaseVulnerability { + """The URL with more information about the vulnerability.""" + link: String! + + """The plugin or module path affected by the vulnerability.""" + modulePath: String! + + """The severity label for the vulnerability.""" + severity: String! + + """The severity score for the vulnerability.""" + severityScore: String +} + +"""Input for creating an edge worker.""" +input CreateEdgeWorkerInput { + """The environment to create the worker on.""" + environmentId: Int! + + """An optional rule scoping which requests the worker runs on.""" + location: EdgeWorkerLocationInput + + """The human-readable name of the edge worker.""" + name: String! + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure + + """The original source code to store for reference.""" + source: String + + """The base64-encoded compiled WASM binary.""" + wasmBinary: String! +} + +"""The custom error page configuration for an environment.""" +type CustomErrorPageConfig { + """The custom error page content stored in the API, when applicable.""" + content: String + + """The strategy used to source the custom error page.""" + strategy: CustomErrorPageConfigStrategy! + + """Suggested custom error page content found in the connected repository.""" + suggestedContentFromRepo: String +} + +"""The available strategies for serving a custom error page.""" +enum CustomErrorPageConfigStrategy { + """Serve the default VIP error page.""" + VIP_DEFAULT + + """Serve a custom error page sourced from the repository.""" + CUSTOM_FROM_REPOSITORY + + """Serve a custom error page stored through the API.""" + CUSTOM_FROM_API +} + +"""A copied database backup available for download.""" +type DBBackupCopy implements Model { + """The configuration used to create the backup copy.""" + config: DBBackupCopyConfig + + """The file path for the copied backup.""" + filePath: String! + + """ + id is not implemented by DBBackupCopy as it does not have an integer id + """ + id: Int +} + +"""The configuration used for a copied database backup.""" +type DBBackupCopyConfig { + """The label assigned to the backup copy.""" + backupLabel: String! + + """The optional network site ID included in the backup copy.""" + networkSiteId: Int + + """The site ID the backup copy belongs to.""" + siteId: Int! + + """The database tables included in the backup copy.""" + tables: [String!]! + + """The user ID that requested the backup copy.""" + userId: String +} + +"""A paginated list of copied database backups.""" +type DBBackupCopyList implements ModelList { + """The cursor for the next page of backup copies.""" + nextCursor: String + + """The backup copies returned in the current page.""" + nodes: [DBBackupCopy!]! + + """The total number of backup copies.""" + total: Int! +} + +"""Input for deleting an edge worker.""" +input DeleteEdgeWorkerInput { + """The identifier of the edge worker to delete.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! +} + +"""Input for deleting an identity provider.""" +input DeleteIdentityProviderInput { + """The identity provider ID to delete.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! +} + +"""The result of deleting an identity provider.""" +type DeleteIdentityProviderPayload { + """Whether the identity provider was deleted.""" + deleted: Boolean +} + +"""A domain for an environment""" +type Domain { + """Is the domain currently active?""" + active: Boolean + + """The active certificate of the domain""" + certificate: Certificate + + """The matching certificates of the domain""" + certificates( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of certificates to return.""" + first: Int + ): CertificateList + + """The date the domain was added to the system""" + createdAt: String + + """What is the IP of the domain and does it point to VIP?""" + dns: DomainDNSRecord + + """When was the email deliverability last checked?""" + emailDeliverabilityLastCheckedAt: String + + """The environment this domain belongs to""" + environment: AppEnvironment + + """ + Does this domain have a valid TLS certificate? (Note: SSL is a misnomer there; we are using TLS certificates.) + """ + hasSSL: Boolean + + """The unique ID for the domain""" + id: Int + + """Is this a default domain? (*.go-vip.co / *.go-vip.net)""" + isDefault: Boolean + + """Is the DKIM record valid?""" + isDkimValid: Boolean + + """Is the DMARC record valid?""" + isDmarcValid: Boolean + + """Is the domain using a Let's Encrypt certificate""" + isLetsEncrypt: Boolean + + """Is this the primary domain for the environment?""" + isPrimary: Boolean + + """Is the SPF record valid?""" + isSpfValid: Boolean + + """Is the domain ownership verified?""" + isVerified: Boolean + + """What are the issues that may block LE provisioning for this domain?""" + letsEncryptCompatibility: [DomainLetsEncryptCompatibility] + + """What is the status of LE provisioning?""" + letsEncryptStatus: [DomainLetsEncryptStatus] + + """The domain name (i.e. something like example.com or sub.example.com)""" + name: String! + + """The generated TXT record for the domain""" + verificationCode: String + + """The wildcard value for the current domain""" + wildcard: String +} + +"""DNS details for a domain.""" +type DomainDNSRecord { + """Whether VIP response headers were observed for the domain.""" + hasVIPHeaders: Boolean + + """The resolved IP addresses for the domain.""" + ip: [String] + + """Whether the domain points to VIP.""" + isVIP: Boolean +} + +"""A compatibility issue that can block Let's Encrypt provisioning.""" +type DomainLetsEncryptCompatibility { + """Recommended action to resolve the issue.""" + actionable: String + + """A machine-readable compatibility code.""" + code: String + + """The affected domain.""" + domain: String + + """An explanation of the compatibility issue.""" + explanation: String + + """Whether the issue is DNS-related.""" + isDNSIssue: Boolean + + """Whether the issue is fatal.""" + isFatal: Boolean + + """A short title for the compatibility issue.""" + title: String +} + +"""The current Let's Encrypt provisioning status for a domain.""" +type DomainLetsEncryptStatus { + """Whether the status indicates a broken state.""" + broken: Boolean + + """The latest error message, if any.""" + errorMessage: String + + """The certificate expiration date.""" + expirationDate: String + + """The number of failures recorded.""" + failCount: Int + + """When the latest error occurred.""" + lastErrorDateTime: String + + """The status name.""" + name: String + + """When the next retry is scheduled.""" + retryDate: String +} + +"""A paginated list of domains.""" +type DomainList { + """The cursor for the next page of domains.""" + nextCursor: String + + """The domains returned in the current page.""" + nodes: [Domain] + + """The total number of matching domains.""" + total: Int +} + +"""Edge configuration for an environment.""" +type EdgeConfig { + """The access restriction settings.""" + accessRestrictions: EdgeConfigAccessRestrictions! +} + +"""Access restriction settings applied at the edge.""" +type EdgeConfigAccessRestrictions { + """The IP-based access restrictions.""" + ip: EdgeConfigAccessRestrictionsIp + + """The user-agent-based access restrictions.""" + userAgent: EdgeConfigAccessRestrictionsUserAgent +} + +"""IP-based access restriction configuration.""" +type EdgeConfigAccessRestrictionsIp { + """The action to apply to matching IPs.""" + action: EdgeConfigAccessRestrictionsIpAction! + + """The IP groups included in the restriction.""" + groups: [EdgeConfigAccessRestrictionsIpGroup!]! +} + +"""The actions available for IP access restrictions.""" +enum EdgeConfigAccessRestrictionsIpAction { + """Allow matching IPs.""" + allow + + """Deny matching IPs.""" + deny +} + +"""A group of IP access restriction rules.""" +type EdgeConfigAccessRestrictionsIpGroup { + """When the group was created.""" + createdAt: Date! + + """The unique identifier for the group.""" + id: String! + + """The IPs included in the group.""" + ips: [String]! + + """Notes describing the group.""" + notes: String! + + """When the group was last updated.""" + updatedAt: Date! +} + +"""Input for an IP access restriction group.""" +input EdgeConfigAccessRestrictionsIpGroupInput { + """The group ID when updating an existing group.""" + id: String + + """The IPs included in the group.""" + ips: [String]! + + """Notes describing the group.""" + notes: String! +} + +"""User-agent-based access restriction configuration.""" +type EdgeConfigAccessRestrictionsUserAgent { + """The user-agent groups included in the restriction.""" + groups: [EdgeConfigAccessRestrictionsUserAgentGroup] +} + +"""A group of user-agent access restriction rules.""" +type EdgeConfigAccessRestrictionsUserAgentGroup { + """When the group was created.""" + createdAt: Date! + + """The unique identifier for the group.""" + id: String! + + """Notes describing the group.""" + notes: String! + + """The matching rules included in the group.""" + rules: [EdgeConfigAccessRestrictionsUserAgentRule!]! + + """When the group was last updated.""" + updatedAt: Date! +} + +"""The operators available for user-agent access restriction rules.""" +enum EdgeConfigAccessRestrictionsUserAgentOperator { + """Match when the user agent contains the value.""" + contains + + """Match when the user agent exactly equals the value.""" + equals +} + +"""A single user-agent access restriction rule.""" +type EdgeConfigAccessRestrictionsUserAgentRule { + """The operator used to match the user agent.""" + operator: EdgeConfigAccessRestrictionsUserAgentOperator! + + """The value to compare the user agent against.""" + value: String! +} + +"""Input for updating IP access restrictions.""" +input EdgeConfigUpdateIPAccessRestrictionsInput { + """The action to apply to matching IPs.""" + action: EdgeConfigAccessRestrictionsIpAction! + + """The environment ID to update.""" + environmentId: Int! + + """The IP groups to store.""" + groups: [EdgeConfigAccessRestrictionsIpGroupInput]! +} + +"""Input for updating user-agent access restrictions.""" +input EdgeConfigUpdateUserAgentAccessRestrictionsInput { + """The environment ID to update.""" + environmentId: Int! + + """The user-agent groups to store.""" + groups: [EdgeConfigUpdateUserAgentGroupInput!]! +} + +"""Input for a user-agent access restriction group.""" +input EdgeConfigUpdateUserAgentGroupInput { + """The group ID when updating an existing group.""" + id: String + + """Notes describing the group.""" + notes: String! + + """The matching rules included in the group.""" + rules: [EdgeConfigUpdateUserAgentGroupRuleInput!]! +} + +"""Input for a user-agent access restriction rule.""" +input EdgeConfigUpdateUserAgentGroupRuleInput { + """The operator used to match the user agent.""" + operator: EdgeConfigAccessRestrictionsUserAgentOperator! + + """The value to compare the user agent against.""" + value: String! +} + +"""A WASM edge worker deployed to an environment.""" +type EdgeWorker { + """Whether the worker is currently active.""" + active: Boolean! + + """When the worker was created.""" + createdAt: Date! + + """The unique identifier for the edge worker.""" + id: Int! + + """ + An optional rule scoping which requests the worker runs on. Runs on all requests when null. + """ + location: EdgeWorkerLocation + + """The human-readable name of the edge worker.""" + name: String! + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure! + + """The request lifecycle phases the worker runs in.""" + phases: [EdgeWorkerPhase!]! + + """The original source code, if it was stored. Fetched on demand.""" + source: String + + """When the worker was last modified.""" + updatedAt: Date! + + """The base64-encoded compiled WASM binary. Fetched on demand.""" + wasmBinary: String +} + +"""A rule scoping which requests an edge worker runs on.""" +type EdgeWorkerLocation { + """The operator used to match the request path.""" + operator: EdgeWorkerLocationOperator! + + """The value to compare the request path against.""" + value: String! +} + +"""Input for an edge worker location rule.""" +input EdgeWorkerLocationInput { + """The operator used to match the request path.""" + operator: EdgeWorkerLocationOperator! + + """The value to compare the request path against.""" + value: String! +} + +"""The operators available for matching an edge worker location.""" +enum EdgeWorkerLocationOperator { + """Match when the path contains the value.""" + contains + + """Match when the path exactly equals the value.""" + equals + + """Match when the path starts with the value.""" + starts_with + + """Match when the path ends with the value.""" + ends_with +} + +"""The behavior to apply when an edge worker errors at runtime.""" +enum EdgeWorkerOnFailure { + """Continue serving the request as if the worker had not run.""" + continue + + """Fail the request when the worker errors.""" + error +} + +"""The request lifecycle phases an edge worker can run in.""" +enum EdgeWorkerPhase { + """Run while the request is being processed.""" + request + + """Run while the response is being processed.""" + response +} + +"""Input for enabling or disabling identity provider encryption.""" +input EnableIdentityProviderEncryptionInput { + """The identity provider ID to update.""" + identityProviderId: Int + + """The organization ID the identity provider belongs to.""" + organizationId: Int! +} + +"""The result of enabling or disabling identity provider encryption.""" +type EnableIdentityProviderEncryptionPayload { + """The updated identity provider.""" + identityProvider: IdentityProvider +} + +"""Input for enabling phpMyAdmin.""" +input EnablePhpMyAdminInput { + """The environment ID.""" + environmentId: Int! +} + +"""The result of enabling phpMyAdmin.""" +type EnablePhpMyAdminPayload { + """Whether phpMyAdmin was enabled successfully.""" + success: Boolean +} + +"""Customer-provided environment variable / constant""" +type EnvironmentVariable { + """Environment variable name""" + name: String! + + """Environment variable value""" + value: String +} + +"""Input for creating, updating, or deleting an environment variable.""" +input EnvironmentVariableInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the environment""" + environmentId: Int! + + """ + Environment variable name (must consist of uppercase letters, numbers, and underscore + """ + name: String! + + """Whether to reload the site manifest after the operation""" + reloadManifest: Boolean + + """Environment variable value""" + value: String! +} + +"""Customer-provided environment variables / constants""" +type EnvironmentVariablesList { + """The environment variables for this environment""" + nodes: [EnvironmentVariable] + + """The total number of environment variables for this environment""" + total: BigInt +} + +"""The updated environment variable list after a mutation.""" +type EnvironmentVariablesPayload { + """The environment variables currently configured on the environment.""" + environmentVariables: EnvironmentVariablesList +} + +"""Input for generating phpMyAdmin access.""" +input GeneratePhpMyAdminAccessInput { + """The environment ID.""" + environmentId: Int! +} + +"""The result of generating phpMyAdmin access.""" +type GeneratePhpMyAdminAccessPayload { + """When the phpMyAdmin access expires.""" + expiresAt: Date + + """The generated phpMyAdmin URL.""" + url: String +} + +"""An identity provider configured for an organization.""" +type IdentityProvider implements Model { + """Whether the identity provider is active.""" + active: Boolean + + """The callback URL for the identity provider.""" + callbackURL: String + + """The primary signing certificate.""" + certificate: String + + """The expiry date of the primary certificate.""" + certificateExpiryDate: String + + """When the identity provider was created.""" + createdAt: String + + """The dashboard login URL for the identity provider.""" + dashboardLoginURL: String + + """The display name of the identity provider.""" + displayName: String + + """The SAML entry point URL.""" + entryPoint: String + + """When the first successful login occurred.""" + firstSuccessfulLogin: String + + """The unique identifier for the identity provider.""" + id: Int + + """The issuer configured for the identity provider.""" + issuer: String + + """The raw metadata XML for the identity provider.""" + metadataXML: String + + """The organization ID the identity provider belongs to.""" + organizationId: Int + + """The provider type.""" + provider: String + + """The secondary signing certificate, if present.""" + secondaryCertificate: String + + """The expiry date of the secondary certificate.""" + secondaryCertificateExpiryDate: String + + """The expiry date of the signing certificate.""" + signingCertificateExpiryDate: String + + """The public key for encryption or signing.""" + signingCertificatePublicKey: String + + """The slug for the identity provider.""" + slug: String + + """When the identity provider was last updated.""" + updatedAt: String +} + +"""A paginated list of identity providers.""" +type IdentityProviderList implements ModelList { + """The cursor for the next page of identity providers.""" + nextCursor: String + + """The identity providers returned in the current page.""" + nodes: [IdentityProvider] + + """The total number of matching identity providers.""" + total: Int +} + +"""A live backup copy created for an environment.""" +type LiveBackupCopy { + """The configuration used to create the copy.""" + config: LiveBackupCopyConfig! + + """The unique identifier for the copy.""" + copyId: String! + + """When the copy was created.""" + createdAt: Date! + + """The error message, if the copy failed.""" + error: String + + """When the copy expires.""" + expiresAt: Date + + """When the copy finished.""" + finishedAt: Date + + """The size of the copy in bytes.""" + size: BigInt + + """The current status of the copy.""" + status: LiveBackupCopyStatus! +} + +"""The configuration used for a live backup copy.""" +type LiveBackupCopyConfig { + """The subsite IDs included in the copy.""" + subsiteIds: [Int!] + + """The table configuration for the copy.""" + tables: [LiveBackupCopyTableConfig!] + + """The tool used to create the copy.""" + tool: LiveBackupCopyTool! + + """The type of live backup copy.""" + type: LiveBackupCopyType! + + """The WP-CLI command used to generate the copy.""" + wpcliCommand: String +} + +"""Input for starting a live backup copy.""" +input LiveBackupCopyConfigInput { + """The live backup copy configuration payload.""" + config: JSON + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The statuses of a live backup copy.""" +enum LiveBackupCopyStatus { + """The copy is pending.""" + pending + + """The copy is currently in progress.""" + in_progress + + """The copy completed successfully.""" + completed + + """The copy failed.""" + failed +} + +"""A table configuration for a live backup copy.""" +type LiveBackupCopyTableConfig { + """The options applied to the table.""" + options: [LiveBackupCopyTableOptionConfig!] + + """The table name.""" + table: String! +} + +"""An option applied to a table in a live backup copy.""" +type LiveBackupCopyTableOptionConfig { + """The option key.""" + key: String! + + """The option value.""" + value: String! +} + +"""The tools available for live backup copies.""" +enum LiveBackupCopyTool { + """Use `mysqldump` to create the copy.""" + mysqldump + + """Use `mydumper` to create the copy.""" + mydumper +} + +"""The supported live backup copy modes.""" +enum LiveBackupCopyType { + """Copy the full database.""" + full + + """Copy selected tables.""" + tables + + """Copy selected subsite IDs.""" + subsite_ids + + """Copy data selected by a WP-CLI command.""" + wpcli_command +} + +"""A media export generated for an environment.""" +type MediaExport { + """When the export was created.""" + createdAt: String + + """The environment ID the media export belongs to.""" + environmentId: Int + + """Any error details for the export.""" + error: MediaExportError + + """When the export expires.""" + expiresAt: String + + """The number of files processed so far.""" + filesProcessed: Int + + """The total number of files in the export.""" + filesTotal: Int + + """The unique identifier for the media export.""" + id: BigInt + + """The current export status.""" + status: String + + """The subsite included in the export, if any.""" + subsite: WPSite + + """The total number of archive files generated.""" + totalArchiveFiles: Int + + """The total size of the export in bytes.""" + totalSizeInBytes: Float + + """The user who started the export.""" + user: WPCLICommandUser +} + +"""Error details for a media export.""" +type MediaExportError { + """Global errors that apply to the whole export.""" + globalErrors: [String] + + """Whether the export includes file-level errors.""" + hasFileErrors: Boolean +} + +"""A paginated list of media exports.""" +type MediaExportsList { + """The cursor for the next page of media exports.""" + nextCursor: String + + """The media exports returned in the current page.""" + nodes: [MediaExport] + + """The total number of matching media exports.""" + total: Int +} + +"""Input for adding a new domain.""" +input NewDomain { + """The domain name to add.""" + name: String! +} + +"""A list of billable request statistics for an organization.""" +type OrgRequestStatsList { + """The request statistics rows.""" + nodes: [SiteRequestStat]! + + """The total number of statistics rows returned.""" + total: BigInt! +} + +"""An authentication domain configured for an organization.""" +type OrganizationAuthDomain implements Model { + """Whether the auth domain is active.""" + active: Boolean + + """When the auth domain was created.""" + createdAt: String + + """The domain value.""" + domain: String + + """The unique identifier for the auth domain.""" + id: Int + + """The organization ID the auth domain belongs to.""" + organizationId: Int +} + +"""Input for creating or updating an organization auth domain.""" +input OrganizationAuthDomainCreateInput { + """Whether the auth domain should be active.""" + active: Boolean + + """The domain value to save.""" + domain: String! + + """The auth domain ID when updating an existing record.""" + id: Int + + """The organization ID the auth domain belongs to.""" + organizationId: Int! +} + +"""Input for deleting an organization auth domain.""" +input OrganizationAuthDomainDeleteInput { + """The auth domain ID to delete.""" + id: Int! +} + +"""The result of deleting an organization auth domain.""" +type OrganizationAuthDomainDeletePayload { + """Whether the auth domain was deleted.""" + deleted: Boolean +} + +"""A paginated list of organization auth domains.""" +type OrganizationAuthDomainList implements ModelList { + """The cursor for the next page of auth domains.""" + nextCursor: String + + """The auth domains returned in the current page.""" + nodes: [OrganizationAuthDomain] + + """The total number of auth domains.""" + total: Int +} + +"""The result of saving an organization auth domain.""" +type OrganizationAuthDomainPayload { + """The saved auth domain.""" + authDomain: OrganizationAuthDomain +} + +"""Input for replacing all auth domains on an organization.""" +input OrganizationAuthDomainReplaceInput { + """The complete list of domains to store.""" + domains: [String!]! + + """The organization ID whose auth domains should be replaced.""" + organizationId: Int! +} + +"""The result of replacing an organization's auth domains.""" +type OrganizationAuthDomainReplacePayload { + """The auth domains after replacement.""" + authDomains: [OrganizationAuthDomain] + + """The organization whose auth domains were replaced.""" + organization: Organization +} + +"""The phpMyAdmin status for an environment.""" +type PHPMyAdminStatus { + """The current phpMyAdmin status value.""" + status: String +} + +"""Input for requesting a feature upgrade.""" +input RequestFeatureUpgradeInput { + """The optional application ID the upgrade applies to.""" + appId: Int + + """The feature being requested.""" + feature: String! + + """The organization ID requesting the upgrade.""" + organizationId: Int! +} + +"""The result of a feature upgrade request.""" +type RequestFeatureUpgradePayload { + """Whether the feature upgrade request was accepted.""" + success: Boolean +} + +"""Request statistics for an application environment.""" +type RequestStats { + """The number of Automattic-cached API requests.""" + apiA8cCached: BigInt + + """The number of Automattic-uncached API requests.""" + apiA8cUncached: BigInt + + """The number of cached API requests.""" + apiCached: BigInt + + """The number of uncached API requests.""" + apiUncached: BigInt + + """The number of Automattic-cached application requests.""" + appA8cCached: BigInt + + """The number of Automattic-uncached application requests.""" + appA8cUncached: BigInt + + """The number of cached application requests.""" + appCached: BigInt + + """The number of uncached application requests.""" + appUncached: BigInt + + """When the statistics row was created.""" + createdAt: String + + """The date the statistics apply to.""" + date: String + + """The environment ID the statistics belong to.""" + environmentId: Int + + """The unique identifier for the request statistics row.""" + id: Int + + """The number of Automattic-cached static asset requests.""" + staticA8cCached: BigInt + + """The number of Automattic-uncached static asset requests.""" + staticA8cUncached: BigInt + + """The number of cached static asset requests.""" + staticCached: BigInt + + """The number of uncached static asset requests.""" + staticUncached: BigInt +} + +"""A list of request statistics rows.""" +type RequestStatsList { + """The request statistics rows.""" + nodes: [RequestStats] + + """The total number of request statistics rows.""" + total: Int +} + +"""Input for creating or updating an identity provider.""" +input SaveIdentityProviderInput { + """Whether the identity provider should be active.""" + active: Boolean! + + """The primary signing certificate.""" + certificate: String! + + """The display name of the identity provider.""" + displayName: String + + """The SAML entry point URL.""" + entryPoint: String + + """The identity provider ID when updating.""" + id: Int + + """The issuer configured for the identity provider.""" + issuer: String + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """The provider type.""" + provider: String! + + """The secondary signing certificate, if present.""" + secondaryCertificate: String + + """The slug for the identity provider.""" + slug: String +} + +"""The result of saving an identity provider.""" +type SaveIdentityProviderPayload { + """The saved identity provider.""" + identityProvider: IdentityProvider +} + +"""Input for enabling or disabling an edge worker.""" +input SetEdgeWorkerActiveInput { + """Whether the worker should be active.""" + active: Boolean! + + """The identifier of the edge worker to toggle.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! +} + +"""Input for updating identity provider validation settings.""" +input SetIdentityProviderValidationsInput { + """The identity provider ID to update.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """Whether to validate the SAML audience.""" + validateAudience: Boolean! + + """Whether SAML assertions must be signed.""" + wantAssertionsSigned: Boolean! + + """Whether AuthnResponse documents must be signed.""" + wantAuthnResponseSigned: Boolean! +} + +"""The result of updating identity provider validation settings.""" +type SetIdentityProviderValidationsPayload { + """The identity provider ID that was updated.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """Whether audience validation is enabled.""" + validateAudience: Boolean! + + """Whether assertion signing is required.""" + wantAssertionsSigned: Boolean! + + """Whether AuthnResponse signing is required.""" + wantAuthnResponseSigned: Boolean! +} + +"""Billable request statistics for a site.""" +type SiteRequestStat { + """The billable API request count for the selected period.""" + billableApiRequestCount: BigInt! + + """The billable application request count for the selected period.""" + billableAppRequestCount: BigInt! + + """The site ID the request statistics belong to.""" + clientSiteId: BigInt! + + """The daily billable API request count for the site.""" + dailyBillableApiRequestCount: BigInt! + + """The daily billable application request count for the site.""" + dailyBillableAppRequestCount: BigInt! + + """The date for the request statistics.""" + date: String! + + """The aggregation resolution used for the statistics.""" + resolution: String! +} + +"""Configuration options for starting a media export.""" +input StartMediaExportConfigOptions { + """A regex used to filter exported files.""" + regex: String + + """The subsite ID to export media from.""" + subsiteId: Int +} + +"""Input for starting a media export.""" +input StartMediaExportInput { + """The application ID that owns the environment.""" + appId: Int + + """The export configuration options.""" + config: StartMediaExportConfigOptions + + """The environment ID to export media from.""" + environmentId: Int +} + +"""The result of starting a media export.""" +type StartMediaExportPayload { + """The media export that was created.""" + mediaExport: MediaExport + + """A human-readable message about the export request.""" + message: String + + """Whether the export request succeeded.""" + success: Boolean +} + +"""Visitor counts for a single date.""" +type Stats { + """The daily unique visitors count for the date.""" + dailyUniqueVisitorsCount: Int + + """The date the visitor counts apply to.""" + date: String! + + """The monthly unique visitors count for the date.""" + monthlyUniqueVisitorsCount: Int! +} + +"""A list of visitor count rows.""" +type StatsList { + """The visitor count rows.""" + nodes: [Stats]! + + """The total number of visitor count rows returned.""" + total: BigInt! +} + +"""The result of verifying a Tollbit domain.""" +type TollbitDomainVerificationResult { + """The domain that was verified.""" + domain: String + + """An error returned during verification, if any.""" + error: String + + """Whether the domain was verified successfully.""" + isVerified: Boolean +} + +"""Input for triggering an Agentforce sync""" +input TriggerAgentforceSyncInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to sync""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +"""Response payload for triggering an Agentforce sync""" +type TriggerAgentforceSyncPayload { + """ISO 8601 timestamp when the sync completed""" + completedAt: String + + """Number of items deleted""" + deleted: Int + + """Error message if the sync failed""" + error: String + + """Number of items that failed to sync""" + failed: Int + + """ID of the last post processed""" + lastPostId: Int + + """Human-readable status message""" + message: String + + """Raw output from the WP-CLI sync command (backward compatibility)""" + output: String! + + """Completion percentage (0-100)""" + percentage: Float + + """List of post types included in the sync""" + postTypes: [String!] + + """Number of items processed so far""" + processed: Int + + """Number of items skipped""" + skipped: Int + + """ISO 8601 timestamp when the sync started""" + startedAt: String + + """Current status of the sync operation""" + status: String + + """Whether the sync operation was successful""" + success: Boolean + + """Number of items successfully synced""" + synced: Int + + """Total number of items to process""" + total: Int + + """ISO 8601 timestamp when the sync was last updated""" + updatedAt: String +} + +"""Input for updating an environment's custom error page configuration.""" +input UpdateCustomErrorPageConfigInput { + """The custom error page content to store when using the API strategy.""" + content: String + + """The environment ID to update.""" + environmentId: Int! + + """The strategy to apply.""" + strategy: CustomErrorPageConfigStrategy! +} + +"""Input for updating an edge worker.""" +input UpdateEdgeWorkerInput { + """The identifier of the edge worker to update.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! + + """A new rule scoping which requests the worker runs on.""" + location: EdgeWorkerLocationInput + + """A new human-readable name for the edge worker.""" + name: String + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure + + """New source code to store for reference.""" + source: String + + """A new base64-encoded compiled WASM binary. Re-validated when provided.""" + wasmBinary: String +} + +"""The result of validating phpMyAdmin access.""" +type ValidatePhpMyAdminAccessPayload { + """Whether phpMyAdmin access is valid.""" + success: Boolean +} + +"""Input for verifying a DNS TXT record.""" +input VerifyDnsTxtRecordInput { + """The domain ID to verify.""" + id: Int +} + +"""The result of verifying a DNS TXT record.""" +type VerifyDnsTxtRecordPayload { + """Whether the TXT record is valid.""" + valid: Boolean +} + +"""Visitor statistics for a Parse.ly site.""" +type VisitorsStats { + """The Parse.ly site ID the statistics belong to.""" + parselySiteId: String! + + """The visitor statistics for the site.""" + stats: StatsList! +} + +"""A list of Parse.ly visitor statistics.""" +type VisitorsStatsList { + """The visitor statistics entries.""" + nodes: [VisitorsStats]! + + """The total number of sites returned.""" + total: BigInt! +} + +"""A WP-CLI command executed on an application environment.""" +type WPCLICommand { + """The WP-CLI command that was executed.""" + command: String + + """When the command was created.""" + createdAt: String + + """When the command ended.""" + endedAt: String + + """The environment ID the command ran on.""" + environmentId: Int + + """The GUID for the command.""" + guid: String + + """The unique identifier for the command.""" + id: Int + + """When the command started.""" + startedAt: String + + """The current status of the command.""" + status: String + + """The user that triggered the command.""" + user: WPCLICommandUser + + """The user ID that triggered the command.""" + userId: Int +} + +"""A paginated list of WP-CLI commands.""" +type WPCLICommandList { + """The cursor for the next page of commands.""" + nextCursor: String + + """The commands returned in the current page.""" + nodes: [WPCLICommand] + + """The total number of matching commands.""" + total: Int +} + +"""The user who triggered a WP-CLI command.""" +type WPCLICommandUser { + """The display name of the user.""" + displayName: String + + """The user's GitHub username.""" + githubUsername: String + + """The unique identifier for the user.""" + id: Int + + """Whether the user is a VIP user.""" + isVIP: Boolean + + """The user's WordPress.com username.""" + wpcomUsername: String +} + +"""SSH credentials for running a WP-CLI command.""" +type WPCliSSHAuthentication { + """The SSH host.""" + host: String! + + """The passphrase for the private key.""" + passphrase: String! + + """The SSH port.""" + port: String! + + """The private key used for authentication.""" + privateKey: String! + + """The SSH username.""" + username: String! +} + +"""WordPress installation details for an application environment.""" +type WPInstallation { + """Core WordPress Site Installation Details""" + core: WPInstallationCoreDetails + + """App Environment Name""" + environmentName: String + + """Details about Jetpack""" + jetpack: WPInstallationJetpackDetails + + """Details about all plugins installed""" + plugins: [WPInstallationPluginDetails!] + + """Details about Security Boost""" + securityBoost: WPInstallationSecurityBoostDetails + + """App Environment / GOOP Site ID""" + siteId: Int + + """Last updated timestamp of the Site Installation Details""" + timestamp: BigInt +} + +"""Core metadata about a WordPress installation.""" +type WPInstallationCoreDetails { + """Is WordPress Multisite Installation""" + isMultisite: Boolean + + """WordPress Installation PHP Version""" + phpVersion: String + + """WordPress Installation Version""" + wpVersion: String +} + +"""Jetpack details for a WordPress installation.""" +type WPInstallationJetpackDetails { + """Is Jetpack available on WordPress Installation""" + available: Boolean + + """Jetpack Version""" + version: String + + """VIP Jetpack Version""" + vipVersion: String +} + +"""Plugin details reported for a WordPress installation.""" +type WPInstallationPluginDetails { + """WordPress Plugin activated by""" + activatedBy: String + + """Is WordPress Plugin active""" + active: Boolean! + + """WordPress Plugin update download link""" + downloadLink: String + + """WordPress Plugin available update version""" + hasUpdate: String + + """WordPress Plugin marketplace""" + marketplace: String + + """WordPress Plugin name""" + name: String! + + """WordPress Plugin path""" + path: String! + + """WordPress Plugin slug""" + slug: String + + """WordPress Plugin version""" + version: String! +} + +"""Security Boost details for a WordPress installation.""" +type WPInstallationSecurityBoostDetails { + """Inactive users count across all blogs""" + inactiveUsersCountAllBlogs: Int + + """Two factor authentication status""" + twoFactorStatus: WPInstallationTwoFactorStatus + + """Users without 2FA count across all blogs""" + usersWithout2faCountAllBlogs: Int +} + +""" +Two-factor authentication enforcement details for a WordPress installation. +""" +type WPInstallationTwoFactorStatus { + """Has enable two factor filter""" + hasEnableTwoFactorFilter: Boolean + + """Has two factor forced filter""" + hasTwoFactorForcedFilter: Boolean + + """Is enforced globally""" + isEnforcedGlobally: Boolean + + """Is entirely disabled""" + isEntirelyDisabled: Boolean + + """Is not enforced globally""" + isNotEnforcedGlobally: Boolean +} + +"""A WordPress site or subsite within an environment.""" +type WPSite { + """WordPress Site/Blog ID""" + blogId: Int + + """List of WordPress PHP defines/constants used in the blog""" + constants: [WPSitePhpConstants] + + """WordPress Home URL option""" + homeUrl: String + + """[DEPRECATING SOON] Alias for blogId""" + id: Int + + """WP Site Installation Details""" + installation: WPInstallation + + """[DEPRECATING SOON] Is blog active""" + isActive: Boolean + + """Jetpack Details""" + jetpack: WPSiteJetpackDetails + + """[DEPRECATING SOON] Alias for jetpack""" + jetpackDetails: WPSiteJetpackDetails + + """Launched status of the subsite""" + launchStatus: WPSiteLaunchStatus + + """Details about Parse.ly plugin (wp-parsely) usage""" + parsely: WPSiteParselyDetails + + """List of enabled plugins on the blog""" + plugins: [String] + + """WordPress Site URL option""" + siteUrl: String + + """Last updated timestamp of the Site Details""" + timestamp: BigInt +} + +"""Jetpack details for a WordPress site.""" +type WPSiteJetpackDetails { + """Is Jetpack Active""" + active: Boolean + + """[DEPRECATING SOON] Jetpack Cache Site ID""" + cacheSiteId: Int + + """Jetpack Cache Site ID""" + id: String + + """Enabled Jetpack modules""" + modules: [String] +} + +"""The launch states for a WordPress site.""" +enum WPSiteLaunchStatus { + """The site is launched.""" + LAUNCHED + + """The site is not launched.""" + NOT_LAUNCHED + + """The site is currently launching.""" + LAUNCHING + + """The site launch state is unknown.""" + UNKNOWN +} + +"""Variables for the UpdateWPSiteLaunchStatus mutation""" +input WPSiteLaunchStatusInput { + """Unique ID of the application""" + appId: Int! + + """Unique ID of the environment""" + environmentId: Int! + + """Updated launch status of the network site""" + launchStatus: WPSiteLaunchStatus! + + """ID of the network site (subsite) being updated""" + networkSiteId: Int! +} + +"""Variables for the UpdateWPSiteLaunchStatus mutation""" +type WPSiteLaunchStatusPayload { + """The application that owns the site.""" + app: App + + """The environment that owns the site.""" + environment: AppEnvironment + + """Updated launch status of the network site""" + launchStatus: String + + """ID of the network site (subsite) being updated""" + networkSiteId: Int +} + +"""A paginated list of WordPress sites.""" +type WPSiteList { + """The cursor for the next page of WordPress sites.""" + nextCursor: String + + """The WordPress sites returned in the current page.""" + nodes: [WPSite] + + """The total number of matching WordPress sites.""" + total: Int +} + +"""Parse.ly configuration values for a WordPress site.""" +type WPSiteParselyConfigs { + """Does the site have a Parse.ly API Secret configured?""" + haveApiSecret: Boolean + + """Is autotrack disabled (to allow Dynamic Tracking to be used)?""" + isAutotrackingDisabled: Boolean + + """Is JavaScript Tracking disabled?""" + isJavascriptDisabled: Boolean + + """Is the site pinned to the specific plugin version?""" + isPinnedVersion: Boolean + + """Is JavaScript tracking enabled for logged in users?""" + shouldTrackLoggedInUsers: Boolean + + """Parse.ly Site ID (aka apikey)""" + siteId: String + + """Details about tracked post types""" + trackedPostTypes: [WPSiteParselyTrackedPostTypesConfig] +} + +"""Parse.ly details for a WordPress site.""" +type WPSiteParselyDetails { + """Is wp-parsely active?""" + active: Boolean + + """Details about how the plugin is configured on site""" + configs: WPSiteParselyConfigs + + """How wp-parsely is activated (if active)""" + integrationType: String + + """Version for the wp-parsely plugin""" + version: String +} + +"""A tracked post type configuration for Parse.ly.""" +type WPSiteParselyTrackedPostTypesConfig { + """The slug for the post type""" + name: String + + """ + How is the post type tracked within Parse.ly? (post, non-post, or do-not-track) + """ + trackType: String +} + +"""A PHP constant defined for a WordPress site.""" +type WPSitePhpConstants { + """WordPress PHP Define/Constant key""" + name: String + + """WordPress PHP Define/Constant value""" + value: String +} + +"""The object storage configuration for cloud shipping.""" +union CloudShippingObjectStorageConfig = CloudShippingObjectStorageConfigS3 | CloudShippingObjectStorageConfigGCP | CloudShippingObjectStorageConfigAzure + +"""The result of enabling or disabling defensive mode.""" +type AppEnvironmentDefensiveModePayload { + """The application that owns the environment.""" + app: App + + """Whether defensive mode is enabled.""" + enabled: Boolean +} + +"""Input for selecting an object storage destination.""" +input ObjectStorageConfigInput { + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput +} + +"""Input for deleting defensive mode configuration.""" +input AppEnvironmentDefensiveModeDeleteInput { + """The application ID.""" + id: Int! + + """The environment ID.""" + environmentId: Int! +} + +"""A single live backup copy table option.""" +input LiveBackupCopyTableOptionConfigInput { + """The option key.""" + key: String! + + """The option value.""" + value: String! +} + +"""Configuration for a single table in a live backup copy.""" +input LiveBackupCopyTableConfigInput { + """The table name.""" + table: String! + + """The table-specific options.""" + options: [LiveBackupCopyTableOptionConfigInput!] +} \ No newline at end of file diff --git a/internal/gql/transport.go b/internal/gql/transport.go new file mode 100644 index 000000000..abc591aaa --- /dev/null +++ b/internal/gql/transport.go @@ -0,0 +1,56 @@ +package gql + +import ( + json "encoding/json/v2" + "io" + "net/http" + "net/url" + "strings" +) + +type transport struct { + cfg Config +} + +func newTransport(cfg Config) Doer { return &transport{cfg: cfg} } + +// Do rewrites the request URL to include ?x_query=<operationName> (unless +// TestMode is set, matching the Node behavior in api.ts:127–134). Attaches +// the bearer token if present. +func (t *transport) Do(req *http.Request) (*http.Response, error) { + if !t.cfg.TestMode { + if op, err := operationNameFromBody(req); err == nil && op != "" { + q := req.URL.Query() + q.Set("x_query", op) + req.URL.RawQuery = q.Encode() + } + } + if t.cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+t.cfg.Token) + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + return t.cfg.HTTPClient.Do(req) +} + +// operationNameFromBody peeks the JSON body for "operationName" without +// consuming the reader. +func operationNameFromBody(req *http.Request) (string, error) { + if req.Body == nil { + return "", nil + } + buf, err := io.ReadAll(req.Body) + if err != nil { + return "", err + } + req.Body = io.NopCloser(strings.NewReader(string(buf))) + req.ContentLength = int64(len(buf)) + var doc struct { + OperationName string `json:"operationName"` + } + if err := json.Unmarshal(buf, &doc); err != nil { + return "", err + } + return url.QueryEscape(doc.OperationName), nil +} diff --git a/internal/gql/transport_helper.go b/internal/gql/transport_helper.go new file mode 100644 index 000000000..2fb0033e0 --- /dev/null +++ b/internal/gql/transport_helper.go @@ -0,0 +1,24 @@ +package gql + +import "net/http" + +// HTTPClientWithMiddleware returns an *http.Client whose RoundTripper composes +// the supplied middleware chain via the *Client's Do method. Use this to feed +// the same chain (error -> rechallenge -> retry) to a genqlient graphql.Client +// without duplicating the wiring. +// +// The returned *http.Client and the underlying *Client share the same +// transport.HTTPClient (http.DefaultClient by default), so any rechallenge +// retry stays on the same connection pool. +func HTTPClientWithMiddleware(apiHost, token string, mw []Middleware) *http.Client { + client := NewClient(Config{APIHost: apiHost, Token: token, Middleware: mw}) + return &http.Client{Transport: &doerTransport{c: client}} +} + +// doerTransport adapts a *Client (which exposes Do) to net/http.RoundTripper +// so genqlient's graphql.Client can run through our middleware stack. +type doerTransport struct{ c *Client } + +func (d *doerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return d.c.Do(req) +} diff --git a/internal/httpproxy/callers_test.go b/internal/httpproxy/callers_test.go new file mode 100644 index 000000000..ebabc3ce6 --- /dev/null +++ b/internal/httpproxy/callers_test.go @@ -0,0 +1,211 @@ +package httpproxy + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// tokenBearingSources are the production files whose requests carry a VIP +// credential — a keychain bearer token, a WPVIP_DEPLOY_TOKEN, or a presigned URL +// whose query string is itself the credential. Every one of them must build its +// client from this package. +// +// http.DefaultClient and http.DefaultTransport are the failure mode: they apply +// http.ProxyFromEnvironment, which is the exact inversion of Node's policy — +// HTTPS_PROXY is honoured without the VIP_USE_SYSTEM_PROXY opt-in, and +// VIP_PROXY/SOCKS_PROXY are ignored. The behavioural proofs live in +// httpproxy_test.go, internal/gql/proxy_test.go and internal/upload/proxy_test.go; +// this list is the cheap guard that stops a seventh call site being added +// without one. +// +// TestNoProductionCodeBuildsAnUnproxiedHTTPClient is the complement: this list +// is opt-IN (these named files must reach for the package), that scan is +// opt-OUT (no file anywhere may build a client the package did not vend). +var tokenBearingSources = []string{ + "../gql/client.go", + "../upload/presign.go", + "../auth/logout.go", + "../rechallenge/client.go", + "../wpstream/engineio.go", + "../sqlexport/download.go", + "../telemetry/tracks.go", + "../telemetry/pendo.go", +} + +func TestTokenBearingClientsDoNotUseTheDefaultTransport(t *testing.T) { + for _, rel := range tokenBearingSources { + src, err := os.ReadFile(filepath.Clean(rel)) + if err != nil { + t.Errorf("read %s: %v (did the file move? update tokenBearingSources)", rel, err) + continue + } + // A bare &http.Client{} is just as wrong — it inherits + // http.DefaultTransport's proxy policy — and is not greppable, so + // require the file to reach for this package explicitly. + if !strings.Contains(string(src), "httpproxy.") { + t.Errorf("%s never calls into internal/httpproxy; an http.Client built without "+ + "an explicit Transport inherits http.DefaultTransport's proxy policy", rel) + } + for _, banned := range []string{"http.DefaultClient", "http.DefaultTransport"} { + for _, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue // prose may name it + } + if strings.Contains(trimmed, banned) { + t.Errorf("%s uses %s; use httpproxy.Client()/Transport() so VIP_PROXY is "+ + "honoured and HTTPS_PROXY is not honoured without VIP_USE_SYSTEM_PROXY\n\t%s", + rel, banned, trimmed) + } + } + } + } +} + +// unproxiedConstructors are the ways production code can end up on +// http.DefaultTransport's proxy policy — the inverse of Node's. +// +// http.Get/Head/Post/PostForm are http.DefaultClient in disguise. A +// `&http.Client{...}` literal with no Transport field is the same thing with a +// timeout bolted on, which is what made the four call sites this scan was +// written for look deliberate. +var unproxiedConstructors = []string{ + "http.DefaultClient", + "http.DefaultTransport", + "http.Get(", + "http.Head(", + "http.Post(", + "http.PostForm(", +} + +// scanExemptDirs are the trees the scan does not walk. +// +// - internal/httpproxy is the package that vends the sanctioned constructors; +// it necessarily names http.DefaultTransport in order to clone it. +// - internal/parity is the differential-test harness, gated behind +// `//go:build parity`. It deliberately talks to a local Parker with its own +// client, and its whole point is ambient independence — the Makefile scrubs +// every proxy variable before running it. +var scanExemptDirs = []string{ + filepath.Join("internal", "httpproxy"), + filepath.Join("internal", "parity"), +} + +// TestNoProductionCodeBuildsAnUnproxiedHTTPClient walks every non-test Go file +// under internal/ and cmd/ and fails on any HTTP client that did not come from +// this package. +// +// tokenBearingSources could only ever catch a regression in a file someone had +// already thought about. This scan catches the file nobody thought about: at +// the commit it was written it found four live call sites on +// http.DefaultTransport, one of them (the WordPress version manifest) a request +// Node explicitly routes through createProxyAgent. +func TestNoProductionCodeBuildsAnUnproxiedHTTPClient(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + for _, tree := range []string{"internal", "cmd"} { + walkGoSources(t, filepath.Join(root, tree), root, func(rel string, src []byte) { + for _, line := range codeLines(string(src)) { + for _, banned := range unproxiedConstructors { + if strings.Contains(line.text, banned) { + t.Errorf("%s:%d builds an HTTP client on http.DefaultTransport's proxy "+ + "policy via %s. Use httpproxy.Client()/ClientWithTimeout() so VIP_PROXY is "+ + "honoured and HTTPS_PROXY is not honoured without VIP_USE_SYSTEM_PROXY; use "+ + "httpproxy.DirectClientWithTimeout() when the target is the user's own "+ + "machine and must never be proxied.\n\t%s", + rel, line.num, banned, line.text) + } + } + if lit, ok := clientLiteral(line.text); ok && !strings.Contains(lit, "Transport:") { + t.Errorf("%s:%d constructs http.Client with no Transport, so it inherits "+ + "http.DefaultTransport's proxy policy. Use httpproxy.ClientWithTimeout() "+ + "(or DirectClientWithTimeout() for the user's own machine).\n\t%s", + rel, line.num, line.text) + } + } + }) + } +} + +type sourceLine struct { + num int + text string +} + +// codeLines drops whole-line comments so prose may name the banned symbols — +// several files explain at length why they are NOT using http.DefaultClient. +func codeLines(src string) []sourceLine { + var out []sourceLine + for i, raw := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "//") { + continue + } + out = append(out, sourceLine{num: i + 1, text: trimmed}) + } + return out +} + +// clientLiteral returns the body of an `http.Client{...}` composite literal +// starting on this line, up to the matching brace. A multi-line literal is +// truncated at end of line, which errs in the safe direction: one whose +// Transport field sits on a later line reports a false positive rather than +// letting a real unproxied client through. +func clientLiteral(line string) (string, bool) { + idx := strings.Index(line, "http.Client{") + if idx < 0 { + return "", false + } + rest := line[idx+len("http.Client"):] + depth := 0 + for i, r := range rest { + switch r { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return rest[:i+1], true + } + } + } + return rest, true +} + +func walkGoSources(t *testing.T, dir, root string, fn func(rel string, src []byte)) { + t.Helper() + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + if d.IsDir() { + for _, skip := range scanExemptDirs { + if rel == skip { + return filepath.SkipDir + } + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, readErr := os.ReadFile(filepath.Clean(path)) + if readErr != nil { + return readErr + } + fn(rel, src) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } +} diff --git a/internal/httpproxy/httpproxy.go b/internal/httpproxy/httpproxy.go new file mode 100644 index 000000000..eaee855c6 --- /dev/null +++ b/internal/httpproxy/httpproxy.go @@ -0,0 +1,308 @@ +// Package httpproxy is the Go port of src/lib/http/proxy-agent.ts, plus the +// parts of the `proxy-from-env` npm package that file depends on. +// +// It exists because the two runtimes disagree about the DEFAULT. Node reaches +// the API through node-fetch with an explicit agent (src/lib/api/http.ts:42), +// and node-fetch reads no proxy environment of its own — so an ambient +// HTTPS_PROXY is ignored unless the user sets VIP_USE_SYSTEM_PROXY. Go's +// http.DefaultTransport reads HTTP_PROXY/HTTPS_PROXY unconditionally, so +// vip-next was routing bearer tokens through proxies the Node CLI deliberately +// bypassed, while ignoring the VIP_PROXY/SOCKS_PROXY variables Node does honour. +// +// Every vip-next HTTP client that talks to the VIP API must use Client() or +// Transport() rather than http.DefaultClient. +package httpproxy + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// ProxyURL is the port of createProxyAgent (proxy-agent.ts:20-46). It is shaped +// as an http.Transport.Proxy func: nil means "connect directly". +// +// Precedence, verbatim from the source's own comment: +// +// 1. VIP_PROXY set: a SOCKS proxy, unconditionally — before the opt-in gate +// and before NO_PROXY. This is the pre-system-proxy behaviour and stays +// backward compatible. +// 2. Nothing applicable set: no proxy. +// 3. VIP_USE_SYSTEM_PROXY and SOCKS_PROXY: SOCKS. +// 4. VIP_USE_SYSTEM_PROXY and HTTPS_PROXY: HTTP CONNECT. Note that Node checks +// HTTPS_PROXY for EVERY target, not only https:// ones, and never consults +// HTTP_PROXY at all. +// 5. NO_PROXY alongside the opt-in: see coveredInNoProxy. +// +// Errors are returned rather than swallowed. A proxy the user configured but +// that we cannot honour must fail the request; silently connecting direct is +// how the SOCKS half of this bug went unnoticed. +func ProxyURL(req *http.Request) (*url.URL, error) { + if req == nil || req.URL == nil { + return nil, nil + } + target := req.URL + + // 1. VIP Socks Proxy takes precedence and is fully backward compatible. + if vipProxy := firstEnv("VIP_PROXY", "vip_proxy"); vipProxy != "" { + return socksProxyURL(vipProxy) + } + + // 2-5. System proxy usage, gated on the explicit opt-in. + if os.Getenv("VIP_USE_SYSTEM_PROXY") == "" { + return nil, nil + } + noProxy := firstEnv("NO_PROXY", "no_proxy") + if coveredInNoProxy(target, noProxy) { + return nil, nil + } + if socksProxy := firstEnv("SOCKS_PROXY", "socks_proxy"); socksProxy != "" { + return socksProxyURL(socksProxy) + } + if httpsProxy := firstEnv("HTTPS_PROXY", "https_proxy"); httpsProxy != "" { + return httpsProxyURL(httpsProxy) + } + return nil, nil +} + +// Transport returns an http.Transport with vip-next's proxy policy and +// otherwise the stdlib defaults (connection pooling, timeouts, HTTP/2). +func Transport() *http.Transport { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{Proxy: ProxyURL} + } + t := base.Clone() + t.Proxy = ProxyURL + return t +} + +// Client returns an http.Client with vip-next's proxy policy and no timeout, +// matching http.DefaultClient in every other respect. +func Client() *http.Client { return &http.Client{Transport: Transport()} } + +// ClientWithTimeout is Client with a per-request deadline. +func ClientWithTimeout(d time.Duration) *http.Client { + c := Client() + c.Timeout = d + return c +} + +// DirectClientWithTimeout returns a client that NEVER consults a proxy, for the +// requests whose target is the user's own machine. +// +// The dev-environment health probe is the case that motivated it: it fetches +// https://<slug>.vipdev.site/, a name /etc/hosts maps to 127.0.0.1. ProxyURL +// applies VIP_PROXY unconditionally and exempts no loopback — deliberately, to +// match proxy-from-env — so routing that probe through the policy would break +// every developer with the VIP SOCKS proxy exported: the proxy would resolve +// and dial <slug>.vipdev.site on its OWN side, where the containers do not +// exist. Node does not proxy it either; Lando's health check is internal, and +// the single dev-environment request Node hands to createProxyAgent is the +// WordPress version manifest (dev-environment-core.ts:1044). +// +// This exists so "goes direct" is a decision a reader can grep for. A bare +// &http.Client{} would go direct today for a different, accidental reason — +// http.DefaultTransport ignores VIP_PROXY entirely — and would silently start +// honouring an ambient HTTPS_PROXY, which is the bug this package removed. +func DirectClientWithTimeout(d time.Duration) *http.Client { + t := directTransport() + return &http.Client{Transport: t, Timeout: d} +} + +func directTransport() *http.Transport { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + t := base.Clone() + t.Proxy = nil + return t +} + +// socksProxyURL is the SocksProxyAgent constructor's Go equivalent. +// +// Divergence, deliberate and loud: socks-proxy-agent also speaks socks4 and +// socks4a, which net/http cannot. Rather than fall back to a direct connection +// — the exact silent failure this package exists to remove — an unsupported +// scheme is an error. "socks" is socks5 in socks-proxy-agent, and net/http +// treats socks5 and socks5h identically. +func socksProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid SOCKS proxy %s: %w", redact(raw), err) + } + switch u.Scheme { + case "socks", "": + u.Scheme = "socks5" + case "socks5", "socks5h": + case "socks4", "socks4a": + return nil, fmt.Errorf("SOCKS proxy %s: socks4/socks4a is not supported; use socks5", redact(raw)) + default: + return nil, fmt.Errorf("SOCKS proxy %s: unsupported scheme %q", redact(raw), u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("SOCKS proxy %s has no host", redact(raw)) + } + return u, nil +} + +// redact strips the userinfo from a proxy URL before it can reach an error +// message. Proxy URLs routinely carry credentials, and these errors do not stay +// on the machine: cmd/vip-next/main.go registers an exit hook that ships the +// error text to the telemetry endpoint. The host is deliberately preserved — +// the user needs to know WHICH proxy setting is wrong. +func redact(raw string) string { + if u, err := url.Parse(raw); err == nil && u.User != nil { + return u.Redacted() + } + // Unparseable, or no userinfo. Fall back to a textual cut at "@" so a + // malformed value with an embedded password still cannot escape. + if at := strings.LastIndex(raw, "@"); at >= 0 { + if scheme := strings.Index(raw, "://"); scheme >= 0 && scheme+3 <= at { + return raw[:scheme+3] + "xxxxx@" + raw[at+1:] + } + return "xxxxx@" + raw[at+1:] + } + return raw +} + +// httpsProxyURL is the HttpsProxyAgent constructor's Go equivalent. A value +// with no scheme (`proxy.example:3128`) is read as http://, which is what +// golang.org/x/net/http/httpproxy does; https-proxy-agent's URL parse would +// simply produce a hostless agent, so there is no useful behaviour to copy. +func httpsProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + if u2, err2 := url.Parse("http://" + raw); err2 == nil && u2.Host != "" { + return u2, nil + } + } + if err != nil { + return nil, fmt.Errorf("invalid HTTPS proxy %s: %w", redact(raw), err) + } + if u.Host == "" { + return nil, fmt.Errorf("HTTPS proxy %s has no host", redact(raw)) + } + return u, nil +} + +// coveredInNoProxy ports proxy-agent.ts:60-68. +// +// The early return is load-bearing: getProxyForUrl cannot distinguish "NO_PROXY +// matched" from "no proxy variable applies to this URL", so proxy-agent.ts only +// asks it once NO_PROXY is actually set. The conflation survives anyway in one +// configuration, and it is Node's: with NO_PROXY set and SOCKS_PROXY as the only +// proxy variable, getProxyForUrl returns "" — it has never heard of SOCKS_PROXY +// — so the SOCKS proxy is suppressed even for a host NO_PROXY does not name. +func coveredInNoProxy(target *url.URL, noProxy string) bool { + if noProxy == "" { + return false + } + return getProxyForURL(target) == "" +} + +// defaultPorts mirrors proxy-from-env's DEFAULT_PORTS. +var defaultPorts = map[string]int{ + "ftp": 21, "gopher": 70, "http": 80, "https": 443, "ws": 80, "wss": 443, +} + +// getProxyForURL ports proxy-from-env's getProxyForUrl. +func getProxyForURL(target *url.URL) string { + proto := target.Scheme + hostname := strings.ToLower(target.Hostname()) + if hostname == "" || proto == "" { + return "" + } + port := defaultPorts[proto] + if p := target.Port(); p != "" { + if parsed, err := strconv.Atoi(p); err == nil { + port = parsed + } + } + if !shouldProxy(hostname, port) { + return "" + } + proxy := firstOf( + envAnyCase("npm_config_"+proto+"_proxy"), + envAnyCase(proto+"_proxy"), + envAnyCase("npm_config_proxy"), + envAnyCase("all_proxy"), + ) + if proxy != "" && !strings.Contains(proxy, "://") { + proxy = proto + "://" + proxy + } + return proxy +} + +// shouldProxy ports proxy-from-env's shouldProxy: the NO_PROXY ruleset. +// A "*" alone proxies nothing; a leading "." or "*" is a suffix match; +// "host:port" only applies to that port; anything else is an exact host match. +func shouldProxy(hostname string, port int) bool { + noProxy := strings.ToLower(firstOf(envAnyCase("npm_config_no_proxy"), envAnyCase("no_proxy"))) + if noProxy == "" { + return true + } + if noProxy == "*" { + return false + } + for _, entry := range strings.FieldsFunc(noProxy, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' + }) { + if entry == "" { + continue + } + entryHost := entry + if idx := strings.LastIndex(entry, ":"); idx > 0 { + if entryPort, err := strconv.Atoi(entry[idx+1:]); err == nil { + if entryPort != port { + continue // rule is for a different port + } + entryHost = entry[:idx] + } + } + if !strings.HasPrefix(entryHost, ".") && !strings.HasPrefix(entryHost, "*") { + if hostname == entryHost { + return false + } + continue + } + suffix := strings.TrimPrefix(entryHost, "*") + if strings.HasSuffix(hostname, suffix) { + return false + } + } + return true +} + +// firstEnv returns the first non-empty value among the named variables, in the +// order proxy-agent.ts reads them (UPPER_CASE first, then lower_case). +func firstEnv(names ...string) string { + for _, n := range names { + if v := os.Getenv(n); v != "" { + return v + } + } + return "" +} + +// envAnyCase ports proxy-from-env's getEnv, which checks lower case first. +func envAnyCase(key string) string { + if v := os.Getenv(strings.ToLower(key)); v != "" { + return v + } + return os.Getenv(strings.ToUpper(key)) +} + +func firstOf(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/httpproxy/httpproxy_test.go b/internal/httpproxy/httpproxy_test.go new file mode 100644 index 000000000..4ea1f01a2 --- /dev/null +++ b/internal/httpproxy/httpproxy_test.go @@ -0,0 +1,435 @@ +package httpproxy + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + xproxy "golang.org/x/net/http/httpproxy" +) + +// proxyEnv is every variable the selection logic reads. Tests clear all of +// them and set back only what they mean to exercise, because the ambient shell +// (or `make test-parity-unit-hostile`) may export any of them. +var proxyEnv = []string{ + "VIP_PROXY", "vip_proxy", + "SOCKS_PROXY", "socks_proxy", + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + "VIP_USE_SYSTEM_PROXY", + "npm_config_proxy", "npm_config_https_proxy", "npm_config_http_proxy", "npm_config_no_proxy", +} + +func clearProxyEnv(t *testing.T) { + t.Helper() + for _, k := range proxyEnv { + // An empty value reads the same as unset everywhere the selection + // logic looks (Node tests truthiness; we test != ""), and t.Setenv + // restores the original for us. + t.Setenv(k, "") + } +} + +func mustParse(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func proxyFor(t *testing.T, target string) *url.URL { + t.Helper() + req := &http.Request{URL: mustParse(t, target)} + got, err := ProxyURL(req) + if err != nil { + t.Fatalf("ProxyURL(%s): %v", target, err) + } + return got +} + +// TestSystemProxyIsOptInOnly is the priority half of cutover item 2.14. +// +// Node reaches the API through node-fetch with an explicit agent from +// createProxyAgent (src/lib/api/http.ts:42). node-fetch reads no proxy +// environment of its own, so HTTPS_PROXY alone is DELIBERATELY ignored: the +// module comment (proxy-agent.ts:9-11) says VIP_USE_SYSTEM_PROXY is what opts a +// user in. Go's http.DefaultTransport reads HTTPS_PROXY unconditionally, so a +// user who declined system-proxy use had their bearer token routed through a +// corporate proxy Node bypassed. +// +// The assertion is a direct contrast with the resolver net/http uses by +// default, so it cannot pass by accident: that resolver must select the proxy +// here and ours must not. (x/net's copy is the same code net/http vendors, +// used directly because http.ProxyFromEnvironment caches the environment in a +// sync.Once and would not see t.Setenv.) +func TestSystemProxyIsOptInOnly(t *testing.T) { + clearProxyEnv(t) + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("HTTP_PROXY", "http://corp-proxy.example:3128") + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + + stdlib, err := xproxy.FromEnvironment().ProxyFunc()(req.URL) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if stdlib == nil { + t.Fatal("precondition failed: the stdlib resolver should have picked HTTPS_PROXY") + } + + got, err := ProxyURL(req) + if err != nil { + t.Fatalf("ProxyURL: %v", err) + } + if got != nil { + t.Errorf("HTTPS_PROXY was honoured without VIP_USE_SYSTEM_PROXY: %s", got) + } +} + +// TestSystemProxyHonouredWhenOptedIn is the other side: once the user opts in, +// HTTPS_PROXY applies regardless of the target's scheme (createProxyAgent reads +// HTTPS_PROXY for every URL, not just https ones). +func TestSystemProxyHonouredWhenOptedIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + + for _, target := range []string{"https://api.wpvip.com/graphql", "http://api.wpvip.com/upload"} { + got := proxyFor(t, target) + if got == nil || got.Host != "corp-proxy.example:3128" { + t.Errorf("ProxyURL(%s) = %v, want corp-proxy.example:3128", target, got) + } + } +} + +// TestVIPProxyWinsAndNeedsNoOptIn pins precedence rule 1 in proxy-agent.ts: +// VIP_PROXY is checked before the VIP_USE_SYSTEM_PROXY gate and before +// NO_PROXY, "fully backward compatible" with the pre-system-proxy module. +func TestVIPProxyWinsAndNeedsNoOptIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("SOCKS_PROXY", "socks5://ignored.example:1080") + t.Setenv("HTTPS_PROXY", "http://ignored.example:3128") + t.Setenv("NO_PROXY", "*") + + got := proxyFor(t, "https://api.wpvip.com/graphql") + if got == nil { + t.Fatal("VIP_PROXY must apply with no opt-in and regardless of NO_PROXY") + } + if got.Scheme != "socks5" || got.Host != "127.0.0.1:1080" { + t.Errorf("ProxyURL = %s, want socks5://127.0.0.1:1080", got) + } +} + +// TestSocksProxyPreferredOverHTTPSWhenOptedIn pins rules 3 and 4: with the +// opt-in set, SOCKS_PROXY beats HTTPS_PROXY. +func TestSocksProxyPreferredOverHTTPSWhenOptedIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + + got := proxyFor(t, "https://api.wpvip.com/graphql") + if got == nil || got.Scheme != "socks5" || got.Host != "socks.example:1080" { + t.Errorf("ProxyURL = %v, want socks5://socks.example:1080", got) + } +} + +// TestNoProxyAppliesOnlyToTheSystemProxyBranch pins rule 5, including the +// quirk it inherits from proxy-from-env: coveredInNoProxy asks getProxyForUrl, +// which returns an empty string both when NO_PROXY matches AND when no +// http(s)_proxy applies +// to the URL at all. So a NO_PROXY that does not match still suppresses a +// SOCKS_PROXY-only configuration. +func TestNoProxyAppliesOnlyToTheSystemProxyBranch(t *testing.T) { + t.Run("matching NO_PROXY suppresses the system proxy", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", "api.wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil (host is in NO_PROXY)", got) + } + }) + + t.Run("non-matching NO_PROXY leaves the system proxy in place", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", "internal.example") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil, want the system proxy (host is not in NO_PROXY)") + } + }) + + t.Run("NO_PROXY does not touch VIP_PROXY", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("NO_PROXY", "api.wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil; VIP_PROXY is checked before the NO_PROXY branch") + } + }) + + t.Run("wildcard NO_PROXY suppresses subdomains", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", ".wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil (.wpvip.com covers api.wpvip.com)", got) + } + }) +} + +// TestNoProxyIsIgnoredWhenUnset guards the early return in coveredInNoProxy: +// proxy-from-env cannot express "no NO_PROXY set", so proxy-agent.ts short- +// circuits before calling it. Dropping that check would make every request +// unproxied, since getProxyForUrl knows nothing about SOCKS_PROXY. +func TestNoProxyIsIgnoredWhenUnset(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil, want the SOCKS proxy (NO_PROXY is unset)") + } +} + +// TestNoProxySet returns to the quirk above with a concrete assertion, so a +// future "cleanup" that makes SOCKS_PROXY survive an unrelated NO_PROXY is +// caught as the divergence it would be. +func TestNoProxySetSuppressesSocksOnlyConfig(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + t.Setenv("NO_PROXY", "unrelated.example") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s; getProxyForUrl knows no SOCKS var, so it returns '' "+ + "and coveredInNoProxy reports true — Node's behaviour", got) + } +} + +func TestNoProxyEnvIsAllUnsetByDefault(t *testing.T) { + clearProxyEnv(t) + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil with no proxy variables set", got) + } +} + +func TestUnsupportedSocksSchemeFailsLoudly(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks4://legacy.example:1080") + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + if _, err := ProxyURL(req); err == nil { + t.Error("socks4 is unsupported by net/http; it must fail, not connect direct") + } +} + +// TestProxyErrorsDoNotLeakCredentials guards a path this slice creates. Proxy +// URLs routinely carry userinfo (socks5://user:pass@host), and these errors do +// not stay local: cmd/vip-next/main.go registers an exit hook that ships the +// error text to the telemetry endpoint. Whatever we put in the message leaves +// the machine. +func TestProxyErrorsDoNotLeakCredentials(t *testing.T) { + const secret = "hunter2-proxy-password" + cases := map[string]string{ + "VIP_PROXY": "socks4://alice:" + secret + "@legacy.example:1080", + "SOCKS_PROXY": "gopher://alice:" + secret + "@weird.example:1080", + } + for envVar, value := range cases { + t.Run(envVar, func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv(envVar, value) + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + _, err := ProxyURL(req) + if err == nil { + t.Fatal("expected an error for an unsupported proxy scheme") + } + if strings.Contains(err.Error(), secret) { + t.Errorf("proxy password appears in the error text: %v", err) + } + if !strings.Contains(err.Error(), "legacy.example") && + !strings.Contains(err.Error(), "weird.example") { + t.Errorf("error must still name the host so the user can fix it: %v", err) + } + }) + } +} + +// TestClientHonoursVIPProxy is the end-to-end half, and reproduces the +// empirical finding in the parity review verbatim: with +// VIP_PROXY=socks5://127.0.0.1:<closed>, Node exits 1 (Socket closed) while +// vip-next exited 0, having ignored the variable completely. +// +// The target is a live loopback server on purpose. Neither +// http.ProxyFromEnvironment nor golang.org/x/net/http/httpproxy will ever proxy +// a loopback host, so a client that still reaches the server is proof the +// request went direct. Node has no such exemption (proxy-from-env's shouldProxy +// only consults NO_PROXY), so the request must be attempted through the dead +// SOCKS port and fail. +func TestClientHonoursVIPProxy(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopbackAddr(t)) + + resp, err := Client().Get(target.URL) + if err == nil { + _ = resp.Body.Close() + t.Fatal("request succeeded; VIP_PROXY was ignored and the connection went direct") + } +} + +// TestDirectClientIsNeverProxied covers the other kind of request vip-next +// makes: one whose target is the user's OWN machine. +// +// The dev-environment health probe fetches https://<slug>.vipdev.site/, a name +// /etc/hosts maps to 127.0.0.1. Routing it through the policy would break every +// developer with VIP_PROXY exported — an A8c laptop's normal state — because a +// SOCKS proxy resolves and dials that name on the PROXY's side, where the +// developer's containers do not exist. Node never proxies it either: the one +// dev-environment request it hands to createProxyAgent is the WordPress version +// manifest (dev-environment-core.ts:1044), and Lando's health check is internal. +// +// So this needs to be a deliberate, greppable "never proxy", not a bare +// &http.Client{} that merely happens to go direct today. +// +// Both clients are exercised against the same server under the same environment +// so the assertion cannot pass vacuously: our own policy has no loopback +// exemption, so ClientWithTimeout MUST fail here. If it ever starts succeeding, +// the direct half proves nothing and this test says so. +func TestDirectClientIsNeverProxied(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopbackAddr(t)) + + resp, err := ClientWithTimeout(5 * time.Second).Get(target.URL) + if err == nil { + _ = resp.Body.Close() + t.Fatal("precondition failed: the proxied client reached a loopback target, so this " + + "test can no longer tell a direct client apart from a proxied one") + } + + resp, err = DirectClientWithTimeout(5 * time.Second).Get(target.URL) + if err != nil { + t.Fatalf("DirectClientWithTimeout was routed through VIP_PROXY: %v", err) + } + _ = resp.Body.Close() +} + +// TestDirectClientIgnoresSystemProxyToo pins the same guarantee against the +// variables the stdlib honours by default, at the transport level — a loopback +// target could never demonstrate this, since neither resolver proxies loopback. +func TestDirectClientIgnoresSystemProxyToo(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("HTTP_PROXY", "http://corp-proxy.example:3128") + + req := &http.Request{URL: mustParse(t, "https://example.invalid/health")} + + stdlib, err := xproxy.FromEnvironment().ProxyFunc()(req.URL) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if stdlib == nil { + t.Fatal("precondition failed: the stdlib resolver should have picked HTTPS_PROXY") + } + if got, err := ProxyURL(req); err != nil || got == nil { + t.Fatalf("precondition failed: our own policy should proxy this (got %v, %v)", got, err) + } + + tr, ok := DirectClientWithTimeout(time.Second).Transport.(*http.Transport) + if !ok { + t.Fatalf("DirectClientWithTimeout transport is %T, want *http.Transport", DirectClientWithTimeout(time.Second).Transport) + } + if tr.Proxy != nil { + got, err := tr.Proxy(req) + t.Fatalf("direct transport has a Proxy func returning (%v, %v); it must be nil", got, err) + } +} + +// TestClientDoesNotProxyWithoutOptIn is the security assertion at the client +// level. The proxy is a live loopback recorder and the target is a name that +// cannot resolve, so a request reaching the recorder can only have got there +// through the proxy. +// +// Both halves are exercised in one test on purpose. A client built with the +// policy net/http applies by default hands the request — Authorization header +// and all — straight to a proxy the user never opted into; ours must not. Only +// asserting our own side would pass vacuously, because a DNS failure and a +// declined proxy look identical from the caller. +func TestClientDoesNotProxyWithoutOptIn(t *testing.T) { + seen := 0 + recorder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen++ + w.WriteHeader(http.StatusOK) + })) + defer recorder.Close() + + clearProxyEnv(t) + t.Setenv("HTTPS_PROXY", recorder.URL) + t.Setenv("HTTP_PROXY", recorder.URL) + t.Setenv("ALL_PROXY", recorder.URL) + + const target = "http://vip-cli-parity.invalid/graphql" + + stdlibPolicy := &http.Client{Transport: &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return xproxy.FromEnvironment().ProxyFunc()(r.URL) + }, + }} + if resp, err := stdlibPolicy.Get(target); err == nil { + _ = resp.Body.Close() + } + if seen != 1 { + t.Fatalf("precondition failed: the stdlib policy should have proxied; recorder saw %d", seen) + } + + seen = 0 + if resp, err := Client().Get(target); err == nil { + _ = resp.Body.Close() + } + if seen != 0 { + t.Fatalf("proxy received %d request(s); the token would have gone to a proxy the user never opted into", seen) + } +} + +// closedLoopbackAddr returns a loopback host:port that is guaranteed to refuse +// connections: it binds, reads the assigned port, then closes the listener. +func closedLoopbackAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + return addr +} diff --git a/internal/keychain/fallback.go b/internal/keychain/fallback.go new file mode 100644 index 000000000..7ac199888 --- /dev/null +++ b/internal/keychain/fallback.go @@ -0,0 +1,93 @@ +package keychain + +import ( + json "encoding/json/v2" + "os" + "path/filepath" + "sync" +) + +// FileBackend stores credentials in $Dir/credentials.json with mode 0600. +// Used on hosts without an OS credential store (headless Linux without +// libsecret, some CI). Emits a one-time warning on first use via the +// caller. +type FileBackend struct { + Dir string + mu sync.Mutex +} + +type fileStore struct { + Entries map[string]string `json:"entries"` +} + +func (b *FileBackend) path() string { return filepath.Join(b.Dir, "credentials.json") } + +func key(service, user string) string { return service + "|" + user } + +func (b *FileBackend) load() (*fileStore, error) { + data, err := os.ReadFile(b.path()) + if os.IsNotExist(err) { + return &fileStore{Entries: map[string]string{}}, nil + } + if err != nil { + return nil, err + } + s := &fileStore{} + if err := json.Unmarshal(data, s); err != nil { + return nil, err + } + if s.Entries == nil { + s.Entries = map[string]string{} + } + return s, nil +} + +func (b *FileBackend) save(s *fileStore) error { + if err := os.MkdirAll(b.Dir, 0o700); err != nil { + return err + } + data, err := json.Marshal(s, json.Deterministic(true)) + if err != nil { + return err + } + return os.WriteFile(b.path(), data, 0o600) +} + +func (b *FileBackend) Set(service, user, secret string) error { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return err + } + s.Entries[key(service, user)] = secret + return b.save(s) +} + +func (b *FileBackend) Get(service, user string) (string, error) { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return "", err + } + v, ok := s.Entries[key(service, user)] + if !ok { + return "", ErrNotFound + } + return v, nil +} + +func (b *FileBackend) Delete(service, user string) error { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return err + } + if _, ok := s.Entries[key(service, user)]; !ok { + return ErrNotFound + } + delete(s.Entries, key(service, user)) + return b.save(s) +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go new file mode 100644 index 000000000..8c52f9df5 --- /dev/null +++ b/internal/keychain/keychain.go @@ -0,0 +1,197 @@ +// Package keychain wraps the OS credential store. +// +// On macOS, Windows, and Linux+libsecret it uses zalando/go-keyring. +// The file fallback (Task 9) covers headless Linux where Secret Service +// is not available. +// +// vip-next owns a separate credential namespace so its keyring representation +// cannot overwrite credentials used by the Node CLI. The legacy Node service +// name is retained for read-only, best-effort token fallback: +// +// - vip-next production → "vip-next-cli" +// - Node production → "vip-go-cli" +// - Non-production → "<base>:<sanitized-url>" +// +// where <sanitized-url> is the full API host URL with every non-alphanumeric +// character replaced by "-", matching: +// +// API_HOST.replace(/[^a-z0-9]/gi, '-') (src/lib/token.ts getServiceName) +// +// Callers pass k.Account() — which equals k.Service — as the user argument to +// primary Set/Get/Delete operations. Legacy entries are never written or +// deleted by this package's authentication store. +package keychain + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + + keyring "github.com/zalando/go-keyring" +) + +// ProductionAPIHost is the canonical production endpoint. Both the private +// and legacy namespaces omit a host suffix for this endpoint. +const ProductionAPIHost = "https://api.wpvip.com" + +const ( + // service is the Go CLI's private credential namespace. + service = "vip-next-cli" + // legacyService is the Node CLI namespace used only for best-effort reads. + legacyService = "vip-go-cli" +) + +// ErrNotFound is returned by Get/Delete when the secret does not exist. +var ErrNotFound = errors.New("keychain: secret not found") + +// nonAlphanumeric matches characters that Node replaces with "-". +var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// Backend abstracts the credential store so tests can inject an in-memory +// double and the file fallback can satisfy the same interface. +type Backend interface { + Set(service, user, secret string) error + Get(service, user string) (string, error) + Delete(service, user string) error +} + +// Keychain is a scoped handle to a particular credential namespace. +type Keychain struct { + Backend Backend + Service string + LegacyService string +} + +// New returns a Keychain with private and legacy service names derived from the +// same API host. +// +// It uses the OS keyring where one is available, and falls back to a 0600 file +// store on a headless Linux box where the Secret Service (D-Bus) is not +// reachable so vip-next still works over SSH, in WSL, and in CI. +func New(host string) *Keychain { + backend := chooseBackend(runtime.GOOS, secretServiceReachable, fallbackDir()) + if fb, ok := backend.(*FileBackend); ok { + warnFileFallbackOnce(fb.path()) + } + return &Keychain{ + Backend: backend, + Service: ServiceNameForHost(host), + LegacyService: LegacyServiceNameForHost(host), + } +} + +// Account returns the private service name used as the default account for +// password operations. +func (k *Keychain) Account() string { return k.Service } + +// Set stores secret under the given user account. +func (k *Keychain) Set(user, secret string) error { + return k.Backend.Set(k.Service, user, secret) +} + +// Get retrieves the secret stored under user. Returns ErrNotFound when absent. +func (k *Keychain) Get(user string) (string, error) { + return k.Backend.Get(k.Service, user) +} + +// Delete removes the secret stored under user. Returns ErrNotFound when absent. +func (k *Keychain) Delete(user string) error { + return k.Backend.Delete(k.Service, user) +} + +func serviceNameForHost(base, host string) string { + // Normalise trailing slash so comparison is robust. + normalized := strings.TrimRight(host, "/") + if normalized == ProductionAPIHost { + return base + } + sanitized := nonAlphanumeric.ReplaceAllString(normalized, "-") + return base + ":" + sanitized +} + +// ServiceNameForHost derives vip-next's private service name from an API host. +func ServiceNameForHost(host string) string { + return serviceNameForHost(service, host) +} + +// LegacyServiceNameForHost derives the Node CLI service name used only for +// best-effort token reads. +func LegacyServiceNameForHost(host string) string { + return serviceNameForHost(legacyService, host) +} + +// defaultBackend delegates to zalando/go-keyring (OS credential store). +type defaultBackend struct{} + +func (defaultBackend) Set(svc, user, secret string) error { + return keyring.Set(svc, user, secret) +} + +func (defaultBackend) Get(svc, user string) (string, error) { + v, err := keyring.Get(svc, user) + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNotFound + } + return v, err +} + +func (defaultBackend) Delete(svc, user string) error { + err := keyring.Delete(svc, user) + if errors.Is(err, keyring.ErrNotFound) { + return ErrNotFound + } + return err +} + +// secretServiceProbeUser is a sentinel account used only to probe whether the +// Linux Secret Service is reachable; it is never stored. +const secretServiceProbeUser = "__vip_secret_service_probe__" + +// chooseBackend picks the OS keyring, or the file fallback on a headless Linux +// box where the Secret Service is unavailable. macOS and Windows always have a +// credential store, so their probe is skipped. Kept pure (probe + dir injected) +// so the selection is unit-testable. +func chooseBackend(goos string, keyringReachable func() bool, fileDir string) Backend { + if goos != "linux" || keyringReachable() { + return defaultBackend{} + } + return &FileBackend{Dir: fileDir} +} + +// secretServiceReachable probes the Linux Secret Service with a cheap Get: +// keyring.ErrNotFound means it is reachable (the probe account is simply +// absent); any other error (e.g. no D-Bus session bus on a headless host) +// means it is unavailable. +func secretServiceReachable() bool { + _, err := keyring.Get(service, secretServiceProbeUser) + return err == nil || errors.Is(err, keyring.ErrNotFound) +} + +// fallbackDir is where the file backend writes credentials.json when the OS +// keyring is unavailable — the user config dir (…/vip), alongside where the +// Node CLI's configstore fallback lives. +func fallbackDir() string { + if d, err := os.UserConfigDir(); err == nil && d != "" { + return filepath.Join(d, "vip") + } + if h, err := os.UserHomeDir(); err == nil && h != "" { + return filepath.Join(h, ".vip") + } + return "vip" +} + +// fileFallbackWarnOnce guards the single stderr notice below. +var fileFallbackWarnOnce sync.Once + +// warnFileFallbackOnce prints one stderr notice that credentials are stored in a +// file rather than the OS keyring (the FileBackend's expected caller warning). +func warnFileFallbackOnce(path string) { + fileFallbackWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, "warning: OS keyring unavailable; storing credentials in %s (0600)\n", path) + }) +} diff --git a/internal/keychain/keychain_select_test.go b/internal/keychain/keychain_select_test.go new file mode 100644 index 000000000..915b35cbe --- /dev/null +++ b/internal/keychain/keychain_select_test.go @@ -0,0 +1,34 @@ +package keychain + +import "testing" + +func TestChooseBackend(t *testing.T) { + up := func() bool { return true } + down := func() bool { return false } + + // Headless Linux (Secret Service unreachable) -> file fallback. + if _, ok := chooseBackend("linux", down, "/tmp/vip").(*FileBackend); !ok { + t.Fatalf("linux without a reachable keyring must use the file fallback") + } + // Linux with a working Secret Service -> OS keyring. + if _, ok := chooseBackend("linux", up, "/tmp/vip").(defaultBackend); !ok { + t.Fatalf("linux with a reachable keyring must use the OS keyring") + } + // macOS / Windows always have a credential store; the probe must be skipped. + for _, goos := range []string{"darwin", "windows"} { + probed := false + probe := func() bool { probed = true; return false } + if _, ok := chooseBackend(goos, probe, "/tmp/vip").(defaultBackend); !ok { + t.Fatalf("%s must use the OS keyring", goos) + } + if probed { + t.Fatalf("%s must not probe the Secret Service", goos) + } + } +} + +func TestFallbackDir(t *testing.T) { + if d := fallbackDir(); d == "" { + t.Fatal("fallbackDir must return a non-empty path") + } +} diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go new file mode 100644 index 000000000..ad41454d3 --- /dev/null +++ b/internal/keychain/keychain_test.go @@ -0,0 +1,112 @@ +package keychain + +import ( + "errors" + "os" + "testing" +) + +type memBackend struct { + store map[string]string +} + +func (m *memBackend) Set(service, user, secret string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[service+"|"+user] = secret + return nil +} +func (m *memBackend) Get(service, user string) (string, error) { + v, ok := m.store[service+"|"+user] + if !ok { + return "", ErrNotFound + } + return v, nil +} +func (m *memBackend) Delete(service, user string) error { + delete(m.store, service+"|"+user) + return nil +} + +func TestRoundTrip(t *testing.T) { + k := &Keychain{Backend: &memBackend{}, Service: "vip-go-cli-test"} + + if err := k.Set("rinat", "secret-value"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := k.Get("rinat") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "secret-value" { + t.Errorf("Get = %q, want %q", got, "secret-value") + } +} + +func TestGetMissingReturnsNotFound(t *testing.T) { + k := &Keychain{Backend: &memBackend{}, Service: "vip-go-cli-test"} + _, err := k.Get("absent") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestServiceNamesAreHostSpecific(t *testing.T) { + if got := ServiceNameForHost("https://api.wpvip.com"); got != "vip-next-cli" { + t.Errorf("ServiceNameForHost prod = %q, want %q", got, "vip-next-cli") + } + if got := ServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-next-cli:https---staging-api-wpvip-com-8443" { + t.Errorf("ServiceNameForHost staging = %q, want %q", got, "vip-next-cli:https---staging-api-wpvip-com-8443") + } + if got := LegacyServiceNameForHost("https://api.wpvip.com"); got != "vip-go-cli" { + t.Errorf("LegacyServiceNameForHost prod = %q, want %q", got, "vip-go-cli") + } + if got := LegacyServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-go-cli:https---staging-api-wpvip-com-8443" { + t.Errorf("LegacyServiceNameForHost staging = %q, want %q", got, "vip-go-cli:https---staging-api-wpvip-com-8443") + } +} + +func TestFileBackendRoundTrip(t *testing.T) { + dir := t.TempDir() + b := &FileBackend{Dir: dir} + + if err := b.Set("svc", "user", "secret"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := b.Get("svc", "user") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "secret" { + t.Errorf("Get = %q, want %q", got, "secret") + } + if err := b.Delete("svc", "user"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := b.Get("svc", "user"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestAccountEqualsService(t *testing.T) { + k := &Keychain{Service: "vip-next-cli"} + if k.Account() != "vip-next-cli" { + t.Errorf("Account() = %q, want %q", k.Account(), "vip-next-cli") + } +} + +func TestFileBackendCreatesFileMode0600(t *testing.T) { + dir := t.TempDir() + b := &FileBackend{Dir: dir} + if err := b.Set("svc", "user", "secret"); err != nil { + t.Fatalf("Set: %v", err) + } + info, err := os.Stat(b.path()) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("file mode = %o, want 0600", info.Mode().Perm()) + } +} diff --git a/internal/logsapi/logsapi.go b/internal/logsapi/logsapi.go new file mode 100644 index 000000000..82a76bdf2 --- /dev/null +++ b/internal/logsapi/logsapi.go @@ -0,0 +1,165 @@ +// Package logsapi wraps the GetAppLogs genqlient operation behind a flat +// Go-friendly surface. The schema field is `AppEnvironment.logs(type, +// limit, after)` and returns `AppEnvironmentLogsList` (`nodes`, +// `nextCursor`, `pollingDelaySeconds`). +// +// The Node parity source is src/lib/app-logs/app-logs.ts (getRecentLogs). +// +// We walk the genqlient response via reflection — mirroring the envvar +// package — so callers don't need to know the deeply-nested generated +// type names (e.g. GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList). +package logsapi + +import ( + "context" + "reflect" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// LIMIT_MAX is the server-side ceiling for the `limit` argument on the +// logs query. Mirrors Node's app-logs.ts export. Callers (the polling +// loop in particular) use this as the cap on subsequent fetches. +const LIMIT_MAX = 5000 + +// LogNode is one log line: a timestamp + message. +type LogNode struct { + Timestamp string + Message string +} + +// Page is a single response page from the logs endpoint. +type Page struct { + Nodes []LogNode + NextCursor *string + PollingDelaySeconds int +} + +// RecentLogs runs GetAppLogs and flattens the response into a Page. The +// logType must be one of `app` or `batch` — validation lives at the +// command-line layer to match Node's exact error wording. +func RecentLogs(ctx context.Context, c graphql.Client, appID, envID int64, logType string, limit int, after *string) (*Page, error) { + resp, err := gql.GetAppLogs(ctx, c, appID, envID, gql.AppEnvironmentLogType(logType), int64(limit), after) + if err != nil { + return nil, err + } + return reflectLogsResponse(resp), nil +} + +// reflectLogsResponse walks app → environments[0] → logs → {nodes, +// nextCursor, pollingDelaySeconds}. Uses reflection to avoid coupling +// to genqlient's verbose generated type names (which change whenever +// the operation shape changes). Returns an empty Page (non-nil) on any +// missing field — the command layer treats len(Nodes)==0 as the +// "no logs found" case. +func reflectLogsResponse(v any) *Page { + p := &Page{Nodes: []LogNode{}} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return p + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return p + } + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return p + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return p + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return p + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return p + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return p + } + logs := env.FieldByName("Logs") + for logs.Kind() == reflect.Ptr { + if logs.IsNil() { + return p + } + logs = logs.Elem() + } + if !logs.IsValid() || logs.Kind() != reflect.Struct { + return p + } + if nc := logs.FieldByName("NextCursor"); nc.IsValid() { + switch nc.Kind() { + case reflect.Ptr: + if !nc.IsNil() { + s := nc.Elem().String() + p.NextCursor = &s + } + case reflect.String: + s := nc.String() + p.NextCursor = &s + } + } + if pd := logs.FieldByName("PollingDelaySeconds"); pd.IsValid() { + switch pd.Kind() { + case reflect.Ptr: + if !pd.IsNil() { + p.PollingDelaySeconds = int(pd.Elem().Int()) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.PollingDelaySeconds = int(pd.Int()) + } + } + nodes := logs.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return p + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item LogNode + if f := n.FieldByName("Timestamp"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Timestamp = f.Elem().String() + } + case reflect.String: + item.Timestamp = f.String() + } + } + if f := n.FieldByName("Message"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Message = f.Elem().String() + } + case reflect.String: + item.Message = f.String() + } + } + p.Nodes = append(p.Nodes, item) + } + return p +} diff --git a/internal/logsapi/logsapi_test.go b/internal/logsapi/logsapi_test.go new file mode 100644 index 000000000..6090a6dd3 --- /dev/null +++ b/internal/logsapi/logsapi_test.go @@ -0,0 +1,82 @@ +package logsapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// logsServer returns a stub /graphql endpoint that responds with the given +// JSON body for every request. Sufficient because each RecentLogs call +// fires exactly one query. +func logsServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestRecentLogsHappyPath(t *testing.T) { + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","message":"hello"},{"timestamp":"2024-01-01T00:00:01Z","message":"world"}],"nextCursor":"abc","pollingDelaySeconds":7}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "app", 500, nil) + if err != nil { + t.Fatalf("RecentLogs: %v", err) + } + if len(page.Nodes) != 2 { + t.Fatalf("Nodes len = %d, want 2 (page=%+v)", len(page.Nodes), page) + } + if page.Nodes[0].Timestamp != "2024-01-01T00:00:00Z" || page.Nodes[0].Message != "hello" { + t.Errorf("Nodes[0] = %+v, want {2024-01-01T00:00:00Z hello}", page.Nodes[0]) + } + if page.Nodes[1].Timestamp != "2024-01-01T00:00:01Z" || page.Nodes[1].Message != "world" { + t.Errorf("Nodes[1] = %+v, want {2024-01-01T00:00:01Z world}", page.Nodes[1]) + } + if page.NextCursor == nil || *page.NextCursor != "abc" { + t.Errorf("NextCursor = %v, want abc", page.NextCursor) + } + if page.PollingDelaySeconds != 7 { + t.Errorf("PollingDelaySeconds = %d, want 7", page.PollingDelaySeconds) + } +} + +func TestRecentLogsEmpty(t *testing.T) { + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":15}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "app", 500, nil) + if err != nil { + t.Fatalf("RecentLogs: %v", err) + } + if len(page.Nodes) != 0 { + t.Errorf("Nodes len = %d, want 0; page=%+v", len(page.Nodes), page) + } + if page.NextCursor != nil { + t.Errorf("NextCursor = %v, want nil", page.NextCursor) + } + if page.PollingDelaySeconds != 15 { + t.Errorf("PollingDelaySeconds = %d, want 15", page.PollingDelaySeconds) + } +} + +func TestRecentLogsBatchType(t *testing.T) { + // Same payload as happy path, but with the batch type — exercising the + // enum-cast path through gql.AppEnvironmentLogType. + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[{"timestamp":"t","message":"m"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "batch", 100, nil) + if err != nil { + t.Fatalf("RecentLogs(batch): %v", err) + } + if len(page.Nodes) != 1 || page.Nodes[0].Message != "m" { + t.Errorf("Nodes = %+v, want one {t m}", page.Nodes) + } +} diff --git a/internal/mediaimport/mediaimport.go b/internal/mediaimport/mediaimport.go new file mode 100644 index 000000000..936969ff7 --- /dev/null +++ b/internal/mediaimport/mediaimport.go @@ -0,0 +1,26 @@ +// Package mediaimport ports src/lib/media-import/** — the media-import +// status poller, its progress tracker, and the small helpers the three +// `vip import media*` commands share. +package mediaimport + +import ( + "os" + "strings" +) + +// IsLocalArchive ports isLocalArchive (media-import/utils.ts:3): +// .tar.gz/.tgz/.zip (case-insensitive) AND an existing regular file. +func IsLocalArchive(filePath string) bool { + lower := strings.ToLower(filePath) + if !strings.HasSuffix(lower, ".tar.gz") && !strings.HasSuffix(lower, ".tgz") && + !strings.HasSuffix(lower, ".zip") { + return false + } + fi, err := os.Stat(filePath) + return err == nil && fi.Mode().IsRegular() +} + +// IsSupportedApp ports isSupportedApp (media-file-import.ts:18): +// app.type must be in SUPPORTED_MEDIA_FILE_IMPORT_SITE_TYPES, i.e. +// exactly "WordPress". +func IsSupportedApp(appType string) bool { return appType == "WordPress" } diff --git a/internal/mediaimport/mediaimport_test.go b/internal/mediaimport/mediaimport_test.go new file mode 100644 index 000000000..df22a603c --- /dev/null +++ b/internal/mediaimport/mediaimport_test.go @@ -0,0 +1,46 @@ +package mediaimport + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsLocalArchive(t *testing.T) { + dir := t.TempDir() + mk := func(name string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return p + } + targz := mk("a.tar.gz") + tgz := mk("b.TGZ") // case-insensitive (utils.ts:4 toLowerCase) + zip := mk("c.zip") + sql := mk("d.sql") + + for p, want := range map[string]bool{ + targz: true, tgz: true, zip: true, sql: false, + filepath.Join(dir, "missing.zip"): false, // stat fails -> false + } { + if got := IsLocalArchive(p); got != want { + t.Errorf("IsLocalArchive(%q) = %v, want %v", p, got, want) + } + } + // directory with archive extension -> false (stat.isFile, utils.ts:13) + archiveDir := filepath.Join(dir, "fake.zip") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatal(err) + } + if IsLocalArchive(archiveDir) { + t.Error("directory must not count as a local archive") + } +} + +func TestIsSupportedApp(t *testing.T) { + // SUPPORTED_MEDIA_FILE_IMPORT_SITE_TYPES = ['WordPress'] (media-file-import.ts:16) + if !IsSupportedApp("WordPress") || IsSupportedApp("node") || IsSupportedApp("") { + t.Error("IsSupportedApp must accept exactly 'WordPress'") + } +} diff --git a/internal/mediaimport/status.go b/internal/mediaimport/status.go new file mode 100644 index 000000000..831612b13 --- /dev/null +++ b/internal/mediaimport/status.go @@ -0,0 +1,162 @@ +package mediaimport + +import ( + "context" + "strings" + "time" + + json "encoding/json/v2" + + "encoding/json/jsontext" + + "github.com/fatih/color" +) + +// DefaultPollInterval — IMPORT_MEDIA_PROGRESS_POLL_INTERVAL (status.ts:24). +const DefaultPollInterval = time.Second + +// StatusFetch retrieves the current media-import status; a nil Status +// means the API returned no mediaImportStatus for the env (status.ts:225). +type StatusFetch func(ctx context.Context) (*Status, error) + +// CheckStatusOpts configures CheckStatus. +type CheckStatusOpts struct { + Fetch StatusFetch + Tracker *Tracker + Interval time.Duration + // OnPoll fires after each snapshot is applied, before terminal + // checks — the command renders its Status/App suffix block here. + OnPoll func(overallStatus string) +} + +// MediaImportError ports ImportFailedError (status.ts:106): the terminal +// failure carries the final status payload for buildErrorMessage. +type MediaImportError struct { + ErrorText string + Status string + FailureDetails *FailureDetails +} + +func (e *MediaImportError) Error() string { return e.ErrorText } + +// intervalRamp ports the poll-interval growth (status.ts:258-266): after +// TWO_MINUTES the interval grows by the base amount once per minute. +// (Node's comment says "decrease"; the code adds — port the code.) +type intervalRamp struct { + base time.Duration + current time.Duration + startDate time.Time + ramping bool // Node's `pollIntervalDecreasing` +} + +func newIntervalRamp(base time.Duration, now time.Time) *intervalRamp { + return &intervalRamp{base: base, current: base, startDate: now} +} + +func (r *intervalRamp) next(now time.Time) time.Duration { + r.ramping = r.ramping || r.startDate.Before(now.Add(-2*time.Minute)) + if r.ramping && r.startDate.Before(now.Add(-time.Minute)) { + r.current += r.base + r.startDate = now + } + return r.current +} + +// CheckStatus ports mediaImportCheckStatus's getResults loop +// (status.ts:216-275). The command owns rendering, the error-log +// download flow, and exit codes. +func CheckStatus(ctx context.Context, opts CheckStatusOpts) (*Status, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + ramp := newIntervalRamp(interval, time.Now()) + + for { + st, err := opts.Fetch(ctx) + if err != nil { + // status.ts:232 — reject({error: error.message}) + return nil, &MediaImportError{ErrorText: err.Error()} + } + if st == nil { + // status.ts:227. + return nil, &MediaImportError{ErrorText: "Requested app/environment is not available for this operation. If you think this is not correct, please contact Support."} + } + + status := st.Status + if status == "" { + status = "unknown" // status.ts:237 + } + + opts.Tracker.SetStatus(*st) + + if status == "FAILED" { + // status.ts:241-247. + if opts.OnPoll != nil { + opts.OnPoll("FAILED") + } + return nil, &MediaImportError{ + ErrorText: "Import FAILED", Status: "FAILED", FailureDetails: st.FailureDetails, + } + } + + if opts.OnPoll != nil { + opts.OnPoll(status) + } + + if status == "COMPLETED" || status == "ABORTED" { + // status.ts:253 — both resolve successfully. + return st, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(ramp.next(time.Now())): + } + } +} + +// BuildErrorMessage ports buildErrorMessage (status.ts:110). +func BuildErrorMessage(fe *MediaImportError) string { + if fe.Status == "FAILED" && fe.FailureDetails != nil { + var b strings.Builder + b.WriteString(color.RedString("Import failed at status: ")) + b.WriteString(color.New(color.FgHiRed, color.Bold).Sprint(fe.FailureDetails.PreviousStatus) + "\n") + b.WriteString(color.RedString("Errors:")) + for _, v := range fe.FailureDetails.GlobalErrors { + b.WriteString("\n\t- " + color.New(color.FgHiRed, color.Bold).Sprint(v)) + } + return b.String() + } + message := color.RedString(fe.ErrorText) + message += "\n\nPlease check the status of your Import using `vip import media status @mysite.production`" + message += "\n\nIf this error persists and you are not sure on how to fix, please contact support\n" + return message +} + +// BuildFileErrors ports buildFileErrors (status.ts:134). JSON mode is +// JSON.stringify(data, null, '\t') (format.ts:35) — tab-indented. +func BuildFileErrors(fileErrors []FileError, asJSON bool) string { + if asJSON { + out, err := json.Marshal(fileErrors, jsontext.WithIndent("\t")) + if err != nil { + return "" + } + return string(out) + } + var b strings.Builder + for _, fe := range fileErrors { + name := fe.FileName + if name == "" { + name = "N/A" + } + errs := "unknown error" + if len(fe.Errors) > 0 { + errs = strings.Join(fe.Errors, ", ") + } + b.WriteString("File Name: " + name) + b.WriteString("\n\nErrors:\n\t- " + errs + "\n\n\n\n") + } + return b.String() +} diff --git a/internal/mediaimport/status_test.go b/internal/mediaimport/status_test.go new file mode 100644 index 000000000..80591cdd8 --- /dev/null +++ b/internal/mediaimport/status_test.go @@ -0,0 +1,159 @@ +package mediaimport + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func scripted(snaps []*Status, errs []error) StatusFetch { + i := 0 + return func(ctx context.Context) (*Status, error) { + idx := i + if i < len(snaps)-1 { + i++ + } + var err error + if idx < len(errs) { + err = errs[idx] + } + return snaps[idx], err + } +} + +func TestCheckStatusCompletes(t *testing.T) { + tr := NewTracker() + var polls []string + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{ + {Status: "RUNNING", FilesTotal: 10, FilesProcessed: 5, HasFilesProcessed: true}, + {Status: "COMPLETED", FilesTotal: 10, FilesProcessed: 10, HasFilesProcessed: true}, + }, nil), + Tracker: tr, + Interval: time.Millisecond, + OnPoll: func(s string) { polls = append(polls, s) }, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "COMPLETED" { + t.Errorf("res = %+v", res) + } + if len(polls) < 2 || polls[len(polls)-1] != "COMPLETED" { + t.Errorf("polls = %v", polls) + } +} + +func TestCheckStatusAbortedResolves(t *testing.T) { + tr := NewTracker() + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{{Status: "ABORTED"}}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + if err != nil || res.Status != "ABORTED" { + t.Errorf("res=%+v err=%v", res, err) + } +} + +func TestCheckStatusFailedRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{{ + Status: "FAILED", + FailureDetails: &FailureDetails{ + PreviousStatus: "RUNNING", + GlobalErrors: []string{"boom"}, + }, + }}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + var fe *MediaImportError + if !errors.As(err, &fe) || fe.Status != "FAILED" { + t.Fatalf("err = %v (%T)", err, err) + } + msg := BuildErrorMessage(fe) + if !strings.Contains(msg, "Import failed at status:") || !strings.Contains(msg, "RUNNING") || + !strings.Contains(msg, "boom") { + t.Errorf("msg = %q", msg) + } + if !tr.HasFailure() { + t.Error("tracker must record the failure") + } +} + +func TestCheckStatusNilStatusRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{nil}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + want := "Requested app/environment is not available for this operation. If you think this is not correct, please contact Support." + var fe *MediaImportError + if !errors.As(err, &fe) || fe.ErrorText != want { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusFetchErrorRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{nil}, []error{errors.New("network exploded")}), + Tracker: tr, + Interval: time.Millisecond, + }) + var fe *MediaImportError + if !errors.As(err, &fe) || fe.ErrorText != "network exploded" { + t.Errorf("err = %v", err) + } +} + +func TestBuildErrorMessageGenericFallback(t *testing.T) { + fe := &MediaImportError{ErrorText: "network exploded"} + msg := BuildErrorMessage(fe) + for _, want := range []string{ + "network exploded", + "Please check the status of your Import using `vip import media status @mysite.production`", + "If this error persists and you are not sure on how to fix, please contact support", + } { + if !strings.Contains(msg, want) { + t.Errorf("msg missing %q:\n%s", want, msg) + } + } +} + +func TestBuildFileErrors(t *testing.T) { + fileErrors := []FileError{ + {FileName: "a.jpg", Errors: []string{"too big", "bad name"}}, + {FileName: "", Errors: nil}, + } + txt := BuildFileErrors(fileErrors, false) + if !strings.Contains(txt, "File Name: a.jpg") || !strings.Contains(txt, "too big, bad name") || + !strings.Contains(txt, "File Name: N/A") || !strings.Contains(txt, "unknown error") { + t.Errorf("txt = %q", txt) + } + jsonOut := BuildFileErrors(fileErrors, true) + // format.ts:35 — JSON.stringify(data, null, '\t') + if !strings.Contains(jsonOut, "\t\"fileName\": \"a.jpg\"") { + t.Errorf("json = %q", jsonOut) + } +} + +func TestPollIntervalRamp(t *testing.T) { + // status.ts:258-266: base 1s; after two minutes, +1s every minute. + now := time.Now() + r := newIntervalRamp(time.Second, now) + if got := r.next(now.Add(30 * time.Second)); got != time.Second { + t.Errorf("t+30s = %v, want 1s", got) + } + if got := r.next(now.Add(2*time.Minute + time.Second)); got != 2*time.Second { + t.Errorf("after 2m = %v, want 2s", got) + } + if got := r.next(now.Add(3*time.Minute + 2*time.Second)); got != 3*time.Second { + t.Errorf("after 3m = %v, want 3s", got) + } +} diff --git a/internal/mediaimport/tracker.go b/internal/mediaimport/tracker.go new file mode 100644 index 000000000..3be0faa02 --- /dev/null +++ b/internal/mediaimport/tracker.go @@ -0,0 +1,116 @@ +package mediaimport + +import ( + "fmt" + "strings" + "sync" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/tui" +) + +// FailureDetails mirrors AppEnvironmentMediaImportStatusFailureDetails. +type FailureDetails struct { + PreviousStatus string + GlobalErrors []string + FileErrorsURL string +} + +// FileError mirrors AppEnvironmentMediaImportStatusFailureDetailsFileErrors. +// JSON tags drive both the error-log download decode and the exported +// JSON report shape (status.ts:139-144). +type FileError struct { + FileName string `json:"fileName"` + Errors []string `json:"errors"` +} + +// Status mirrors the subset of AppEnvironmentMediaImportStatus the +// tracker and poller consume (progress.ts:9 + status.ts:36-47). +// HasFilesProcessed distinguishes 0 from absent (Node checks +// `typeof filesProcessed === 'number'`, progress.ts:66). +type Status struct { + ImportID int64 + SiteID int64 + Status string + FilesTotal int64 + FilesProcessed int64 + HasFilesProcessed bool + FailureDetails *FailureDetails +} + +// GlyphForMediaStatus ports media-import/status.ts:83 getGlyphForStatus. +// spinner is the current braille frame. +func GlyphForMediaStatus(status, spinner string) string { + switch status { + case "INITIALIZING": + return "○" + case "INITIALIZED", "RUNNING", "COMPLETING", "RAN", "VALIDATING", "VALIDATED": + return color.HiBlueString(spinner) + case "COMPLETED": + return color.GreenString("✓") + case "FAILED": + return color.RedString("✕") + case "ABORTED", "ABORTING": + return color.YellowString("⚠️") + default: + return "" + } +} + +// Tracker ports MediaImportProgressTracker (media-import/progress.ts:14). +// Frame() renders `<prefix><logs><suffix>` where logs is the one-line +// files-processed summary (progress.ts:70). Implements the commands' +// frameSource interface. Safe for concurrent use (render ticker vs. +// poller goroutine). +type Tracker struct { + mu sync.Mutex + status Status + hasFailure bool + spinnerIdx int + prefix string + suffix string +} + +func NewTracker() *Tracker { return &Tracker{} } + +func (t *Tracker) SetPrefix(p string) { t.mu.Lock(); defer t.mu.Unlock(); t.prefix = p } +func (t *Tracker) SetSuffix(s string) { t.mu.Lock(); defer t.mu.Unlock(); t.suffix = s } + +// AppendSuffix mirrors Node's `progressTracker.suffix += ...` calls in +// the error-log download flow (status.ts:286 etc.). +func (t *Tracker) AppendSuffix(s string) { t.mu.Lock(); defer t.mu.Unlock(); t.suffix += s } + +// SetStatus ports setStatus (progress.ts:38). +func (t *Tracker) SetStatus(s Status) { + t.mu.Lock() + defer t.mu.Unlock() + if s.Status == "FAILED" { + t.hasFailure = true + } + t.status = s +} + +func (t *Tracker) HasFailure() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.hasFailure } + +// Frame ports print (progress.ts:58): prefix + optional progress line + +// suffix. The spinner advances per Frame call (RunningSprite parity). +func (t *Tracker) Frame() string { + t.mu.Lock() + defer t.mu.Unlock() + spinner := tui.SpinnerGlyphs[t.spinnerIdx] + t.spinnerIdx = (t.spinnerIdx + 1) % len(tui.SpinnerGlyphs) + + logs := "" + if t.status.HasFilesProcessed && t.status.FilesTotal > 0 { + pct := 100 * t.status.FilesProcessed / t.status.FilesTotal + logs = fmt.Sprintf("Imported Files: %d/%d - %d%% %s", + t.status.FilesProcessed, t.status.FilesTotal, pct, + GlyphForMediaStatus(t.status.Status, spinner)) + } + var b strings.Builder + b.WriteString(t.prefix) + b.WriteString(logs) + b.WriteString(t.suffix) + return b.String() +} diff --git a/internal/mediaimport/tracker_test.go b/internal/mediaimport/tracker_test.go new file mode 100644 index 000000000..e6ad7c380 --- /dev/null +++ b/internal/mediaimport/tracker_test.go @@ -0,0 +1,62 @@ +package mediaimport + +import ( + "strings" + "testing" +) + +func TestTrackerFrameProgressLine(t *testing.T) { + tr := NewTracker() + tr.SetPrefix("HEAD\n") + tr.SetSuffix("\nTAIL") + tr.SetStatus(Status{Status: "RUNNING", FilesTotal: 200, FilesProcessed: 50, HasFilesProcessed: true}) + frame := tr.Frame() + // progress.ts:70: `Imported Files: 50/200 - 25% <glyph>` + if !strings.Contains(frame, "Imported Files: 50/200 - 25%") { + t.Errorf("frame = %q", frame) + } + if !strings.HasPrefix(frame, "HEAD\n") || !strings.HasSuffix(frame, "\nTAIL") { + t.Errorf("prefix/suffix not rendered: %q", frame) + } +} + +func TestTrackerFrameNoCountsRendersEmptyLogs(t *testing.T) { + tr := NewTracker() + tr.SetStatus(Status{Status: "INITIALIZING"}) + // progress.ts:66: logs only render when filesProcessed is a number AND + // filesTotal is truthy; otherwise prefix+suffix only. + if frame := tr.Frame(); strings.Contains(frame, "Imported Files") { + t.Errorf("frame = %q", frame) + } +} + +func TestTrackerHasFailure(t *testing.T) { + tr := NewTracker() + tr.SetStatus(Status{Status: "FAILED"}) + if !tr.HasFailure() { + t.Error("FAILED status must set hasFailure (progress.ts:39)") + } +} + +func TestGlyphForMediaStatus(t *testing.T) { + // status.ts:83 vocabulary. + for status, want := range map[string]string{ + "INITIALIZING": "○", + "COMPLETED": "✓", + "FAILED": "✕", + "ABORTED": "⚠️", + "ABORTING": "⚠️", + } { + if got := GlyphForMediaStatus(status, "⠋"); !strings.Contains(got, want) { + t.Errorf("GlyphForMediaStatus(%q) = %q, want contains %q", status, got, want) + } + } + if got := GlyphForMediaStatus("bogus", "⠋"); got != "" { + t.Errorf("unknown status must render empty, got %q", got) + } + for _, spinning := range []string{"INITIALIZED", "RUNNING", "COMPLETING", "RAN", "VALIDATING", "VALIDATED"} { + if got := GlyphForMediaStatus(spinning, "⠋"); !strings.Contains(got, "⠋") { + t.Errorf("GlyphForMediaStatus(%q) = %q, want spinner", spinning, got) + } + } +} diff --git a/internal/nodeflags/nodeflags.go b/internal/nodeflags/nodeflags.go new file mode 100644 index 000000000..fce4dc5c4 --- /dev/null +++ b/internal/nodeflags/nodeflags.go @@ -0,0 +1,130 @@ +// Package nodeflags ports the option-value grammar that the Node CLI applies +// to flag values before a handler ever sees them. +// +// Node registers every non-boolean option with commander as `--name [value]` +// (src/lib/cli/command.js:111-114) and hands the raw token to a per-option +// parse function. The parse functions live in +// src/lib/dev-environment/dev-environment-cli.ts and are ported here verbatim, +// including their edge cases. Nothing in this package prompts, validates +// against the network, or touches disk — it is pure value coercion plus the +// argv reshaping that gives cobra commander's optional-value lookahead. +package nodeflags + +import "strings" + +// FalseOptions / TrueOptions mirror dev-environment-cli.ts:924-925. +var ( + FalseOptions = []string{"false", "no", "n", "0"} + TrueOptions = []string{"true", "yes", "y", "1"} +) + +func containsFold(list []string, v string) bool { + lower := strings.ToLower(v) + for _, x := range list { + if x == lower { + return true + } + } + return false +} + +// ProcessBooleanOption ports processBooleanOption (dev-environment-cli.ts:939). +// +// if ( ! value ) { return false; } +// return ! FALSE_OPTIONS.includes( value.toString().toLowerCase() ); +// +// Two consequences worth stating because they are easy to "fix" by accident: +// +// - An unrecognized value is TRUE, not an error. `--xdebug maybe` enables +// Xdebug in Node, so it must enable it here. +// - The empty string is false: JS short-circuits on the falsy value +// before the FALSE_OPTIONS lookup ever runs. +func ProcessBooleanOption(value string) bool { + if value == "" { + return false + } + return !containsFold(FalseOptions, value) +} + +// MediaRedirectDomainError is the UserError message Node throws when the +// media redirect domain is given a truthy word instead of a domain +// (dev-environment-cli.ts:957). +const MediaRedirectDomainError = "Media redirect domain must be a domain name or an URL" + +type mediaRedirectError struct{} + +func (mediaRedirectError) Error() string { return MediaRedirectDomainError } + +// ProcessMediaRedirectDomainOption ports processMediaRedirectDomainOption +// (dev-environment-cli.ts:948). A FALSE_OPTIONS value DISABLES the redirect +// (returns ""); a TRUE_OPTIONS value is a user error; anything else is the +// domain itself. +func ProcessMediaRedirectDomainOption(value string) (string, error) { + if containsFold(FalseOptions, value) { + return "", nil + } + if containsFold(TrueOptions, value) { + return "", mediaRedirectError{} + } + return value, nil +} + +// Kind distinguishes the two arms of Node's `string | boolean` return type. +type Kind int + +const ( + KindBool Kind = iota + KindString +) + +// StringOrBool is the Go shape of Node's `string | boolean` union. +type StringOrBool struct { + Kind Kind + Bool bool + String string +} + +// ProcessStringOrBooleanOption ports processStringOrBooleanOption +// (dev-environment-cli.ts:963). Used by `dev-env create --multisite`, whose +// accepted values are "y"/"subdirectory"/"false". +func ProcessStringOrBooleanOption(value string) StringOrBool { + if value == "" || containsFold(FalseOptions, value) { + return StringOrBool{Kind: KindBool, Bool: false} + } + if containsFold(TrueOptions, value) { + return StringOrBool{Kind: KindBool, Bool: true} + } + return StringOrBool{Kind: KindString, String: value} +} + +// ProcessSlug ports processSlug (dev-environment-cli.ts:979): coerce to a +// string, then toLowerCase. Every Node dev-env bin that +// registers --slug passes this as the option's parse function, so the slug is +// lowercased before it ever reaches the on-disk environment path or the +// compose project name. +func ProcessSlug(value string) string { return strings.ToLower(value) } + +// Component is the Go shape of Node's LocalComponent | ImageComponent +// (dev-environment-cli.ts:217-227). +type Component struct { + Mode string // "local" or "image" + Dir string // set when Mode == "local" + Tag string // set when Mode == "image"; "" mirrors Node's `undefined` +} + +// ProcessComponentOptionInput ports processComponentOptionInput +// (dev-environment-cli.ts:237). The "naive check" for a local path is Node's +// own wording: any value containing a forward or back slash is a directory +// when allowLocal is set. "demo" and "image" resolve to the default image +// (Node returns tag `undefined`), which is why `--app-code demo` must NOT +// become a literal bind-mount path. +func ProcessComponentOptionInput(param string, allowLocal bool) Component { + if allowLocal && strings.ContainsAny(param, `/\`) { + return Component{Mode: "local", Dir: param} + } + tag := param + if param == "demo" || param == "image" { + tag = "" + } + return Component{Mode: "image", Tag: tag} +} diff --git a/internal/nodeflags/nodeflags_test.go b/internal/nodeflags/nodeflags_test.go new file mode 100644 index 000000000..d6883b682 --- /dev/null +++ b/internal/nodeflags/nodeflags_test.go @@ -0,0 +1,138 @@ +package nodeflags + +import "testing" + +// Node: src/lib/dev-environment/dev-environment-cli.ts:939-946 +// +// export function processBooleanOption( value: unknown ): boolean { +// if ( ! value ) { return false; } +// return ! FALSE_OPTIONS.includes( value.toString().toLowerCase() ); +// } +// +// FALSE_OPTIONS = [ 'false', 'no', 'n', '0' ] (line 924). +func TestProcessBooleanOption(t *testing.T) { + cases := []struct { + in string + want bool + }{ + // FALSE_OPTIONS, case-insensitive. + {"false", false}, {"FALSE", false}, + {"no", false}, {"No", false}, + {"n", false}, {"N", false}, + {"0", false}, + // TRUE_OPTIONS. + {"true", true}, {"TRUE", true}, + {"yes", true}, {"y", true}, {"Y", true}, {"1", true}, + // Node does NOT error on unrecognized values: anything not in + // FALSE_OPTIONS is true. + {"maybe", true}, {"nope", true}, {"00", true}, {" n", true}, + // `! value` short-circuit: the empty string is falsy in JS. + {"", false}, + } + for _, c := range cases { + if got := ProcessBooleanOption(c.in); got != c.want { + t.Errorf("ProcessBooleanOption(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// Node: dev-environment-cli.ts:948-961. +func TestProcessMediaRedirectDomainOption(t *testing.T) { + for _, in := range []string{"false", "no", "n", "0", "N", "No"} { + got, err := ProcessMediaRedirectDomainOption(in) + if err != nil { + t.Errorf("ProcessMediaRedirectDomainOption(%q) errored: %v", in, err) + } + if got != "" { + t.Errorf("ProcessMediaRedirectDomainOption(%q) = %q, want \"\" (disabled)", in, got) + } + } + for _, in := range []string{"true", "yes", "y", "1", "Y"} { + if _, err := ProcessMediaRedirectDomainOption(in); err == nil { + t.Errorf("ProcessMediaRedirectDomainOption(%q): want UserError, got nil", in) + } else if err.Error() != "Media redirect domain must be a domain name or an URL" { + t.Errorf("ProcessMediaRedirectDomainOption(%q) error = %q", in, err) + } + } + // Anything else passes through verbatim, including the empty string + // (Node: `( value ?? '' ).toString()` then falls through the two guards). + for _, in := range []string{"example.go-vip.co", "https://example.com", ""} { + got, err := ProcessMediaRedirectDomainOption(in) + if err != nil || got != in { + t.Errorf("ProcessMediaRedirectDomainOption(%q) = (%q, %v), want (%q, nil)", in, got, err, in) + } + } +} + +// Node: dev-environment-cli.ts:963-977. +func TestProcessStringOrBooleanOption(t *testing.T) { + cases := []struct { + in string + wantVal string + wantBool bool + wantKind Kind + }{ + {"", "", false, KindBool}, + {"false", "", false, KindBool}, + {"n", "", false, KindBool}, + {"0", "", false, KindBool}, + {"true", "", true, KindBool}, + {"y", "", true, KindBool}, + {"1", "", true, KindBool}, + {"subdirectory", "subdirectory", false, KindString}, + } + for _, c := range cases { + got := ProcessStringOrBooleanOption(c.in) + if got.Kind != c.wantKind || got.Bool != c.wantBool || got.String != c.wantVal { + t.Errorf("ProcessStringOrBooleanOption(%q) = %+v, want kind=%v bool=%v string=%q", + c.in, got, c.wantKind, c.wantBool, c.wantVal) + } + } +} + +// Node: dev-environment-cli.ts:979-982 — coerce to string, then toLowerCase. +func TestProcessSlug(t *testing.T) { + cases := map[string]string{ + "Example-Site": "example-site", + "MYSITE": "mysite", + "already": "already", + "": "", + "Mixed_Case-1": "mixed_case-1", + } + for in, want := range cases { + if got := ProcessSlug(in); got != want { + t.Errorf("ProcessSlug(%q) = %q, want %q", in, got, want) + } + } +} + +// Node: dev-environment-cli.ts:229-255. +func TestProcessComponentOptionInput(t *testing.T) { + cases := []struct { + param string + allowLocal bool + wantMode string + wantDir string + wantTag string + }{ + // allowLocal + a path separator => local. + {"/Users/x/repo", true, "local", "/Users/x/repo", ""}, + {`C:\repo`, true, "local", `C:\repo`, ""}, + {"./repo", true, "local", "./repo", ""}, + // No separator => image, tag = param. + {"6.4", true, "image", "", "6.4"}, + {"latest", false, "image", "", "latest"}, + // "demo"/"image" => image with NO tag (Node returns undefined). + {"demo", true, "image", "", ""}, + {"image", true, "image", "", ""}, + // allowLocal=false never yields local, even with a separator. + {"/Users/x/repo", false, "image", "", "/Users/x/repo"}, + } + for _, c := range cases { + got := ProcessComponentOptionInput(c.param, c.allowLocal) + if got.Mode != c.wantMode || got.Dir != c.wantDir || got.Tag != c.wantTag { + t.Errorf("ProcessComponentOptionInput(%q, %v) = %+v, want mode=%s dir=%q tag=%q", + c.param, c.allowLocal, got, c.wantMode, c.wantDir, c.wantTag) + } + } +} diff --git a/internal/nodeflags/optionalvalue.go b/internal/nodeflags/optionalvalue.go new file mode 100644 index 000000000..34b2fd48d --- /dev/null +++ b/internal/nodeflags/optionalvalue.go @@ -0,0 +1,123 @@ +package nodeflags + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// optionalValueAnnotation marks a flag as one of Node's `--name [value]` +// optional-value options. +const optionalValueAnnotation = "vip:optional-value" + +// MarkOptionalValue gives the named flags commander's optional-value grammar: +// the bare form takes noOptDefVal, an `=value` form takes that value, and a +// following non-option token is consumed as the value (see +// NormalizeOptionalValues, which supplies the lookahead pflag lacks). +// +// Node registers every non-boolean option as `--name [value]` +// (src/lib/cli/command.js:111-114). vip-next opts in only where the bare form +// carries meaning — the dev-env service toggles and --multisite — because +// elsewhere ("--slug" with no value) Node's bare form yields the boolean +// `true`, which no handler can use. +func MarkOptionalValue(cmd *cobra.Command, noOptDefVal string, names ...string) { + for _, name := range names { + f := cmd.Flags().Lookup(name) + if f == nil { + continue + } + f.NoOptDefVal = noOptDefVal + if f.Annotations == nil { + f.Annotations = map[string][]string{} + } + f.Annotations[optionalValueAnnotation] = []string{"true"} + } +} + +func isOptionalValue(f *pflag.Flag) bool { + return f != nil && len(f.Annotations[optionalValueAnnotation]) > 0 +} + +// isOptionToken ports Node's isOptionToken (src/lib/cli/command.js:129-131): +// a lone "-" is a value, everything else starting with "-" is an option. +func isOptionToken(arg string) bool { return arg != "-" && strings.HasPrefix(arg, "-") } + +// NormalizeOptionalValues rewrites argv so cobra sees `--flag=value` wherever +// commander would have consumed the following token as an optional value. +// +// pflag has no equivalent of commander's optional-value lookahead: once a flag +// carries NoOptDefVal, `-p n` sets the flag to NoOptDefVal and leaves "n" as a +// stray positional. That is exactly the inverted-flag bug this fixes — in Node +// `-p n` DISABLES phpMyAdmin. Rewriting to `-p=n` before cobra parses restores +// commander's grammar without patching pflag. +// +// The rewrite is scoped to the command argv actually targets, so a flag name +// that is optional-value on one command cannot change parsing on another. +// Commands with DisableFlagParsing (vip wp) and everything after a `--` +// terminator are passed through verbatim, matching commander, which stops +// option processing at `--`. +func NormalizeOptionalValues(root *cobra.Command, argv []string) []string { + target, _, err := root.Find(argv) + if err != nil || target == nil || target.DisableFlagParsing { + return argv + } + // Merge inherited persistent flags so an optional-value flag declared on a + // parent is honored on the leaf. + flags := target.Flags() + flags.AddFlagSet(target.InheritedFlags()) + + longs := map[string]*pflag.Flag{} + shorts := map[string]*pflag.Flag{} + flags.VisitAll(func(f *pflag.Flag) { + if !isOptionalValue(f) { + return + } + longs[f.Name] = f + if f.Shorthand != "" { + shorts[f.Shorthand] = f + } + }) + if len(longs) == 0 { + return argv + } + + out := make([]string, 0, len(argv)) + for i := 0; i < len(argv); i++ { + arg := argv[i] + if arg == "--" { + out = append(out, argv[i:]...) + break + } + + switch { + case strings.HasPrefix(arg, "--") && !strings.Contains(arg, "="): + if _, ok := longs[arg[2:]]; !ok { + out = append(out, arg) + continue + } + case len(arg) >= 2 && arg[0] == '-' && arg[1] != '-' && !strings.Contains(arg, "="): + if _, ok := shorts[arg[1:2]]; !ok { + out = append(out, arg) + continue + } + // `-pn`: commander's _combineFlagAndOptionalValue treats the + // remainder of the token as the value. + if len(arg) > 2 { + out = append(out, arg[:2]+"="+arg[2:]) + continue + } + default: + out = append(out, arg) + continue + } + + if i+1 < len(argv) && !isOptionToken(argv[i+1]) { + out = append(out, arg+"="+argv[i+1]) + i++ + continue + } + out = append(out, arg) + } + return out +} diff --git a/internal/nodeflags/optionalvalue_test.go b/internal/nodeflags/optionalvalue_test.go new file mode 100644 index 000000000..8d1bfc845 --- /dev/null +++ b/internal/nodeflags/optionalvalue_test.go @@ -0,0 +1,158 @@ +package nodeflags + +import ( + "slices" + "testing" + + "github.com/spf13/cobra" +) + +// testTree mirrors the shape the real dev-env tree has: a parent, a leaf with +// two optional-value flags plus one ordinary value flag, and a +// DisableFlagParsing leaf (like `vip wp`). +func testTree() *cobra.Command { + root := &cobra.Command{Use: "root"} + + leaf := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + leaf.Flags().StringP("phpmyadmin", "p", "", "") + leaf.Flags().StringP("xdebug", "x", "", "") + leaf.Flags().StringP("slug", "s", "", "") + MarkOptionalValue(leaf, "y", "phpmyadmin", "xdebug") + root.AddCommand(leaf) + + raw := &cobra.Command{Use: "wp", DisableFlagParsing: true, Run: func(*cobra.Command, []string) {}} + root.AddCommand(raw) + + return root +} + +func TestNormalizeOptionalValues(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + { + // commander: "historical behaviour is optional value is following + // arg unless an option" (Command.parseOptions). + "short flag takes the following token", + []string{"create", "-p", "n"}, + []string{"create", "-p=n"}, + }, + { + "long flag takes the following token", + []string{"create", "--phpmyadmin", "n"}, + []string{"create", "--phpmyadmin=n"}, + }, + { + // _combineFlagAndOptionalValue defaults to true in commander. + "attached short value", + []string{"create", "-pn"}, + []string{"create", "-p=n"}, + }, + { + "bare flag at end of argv keeps its NoOptDefVal", + []string{"create", "--phpmyadmin"}, + []string{"create", "--phpmyadmin"}, + }, + { + "following option token is not consumed as a value", + []string{"create", "--phpmyadmin", "--xdebug", "n"}, + []string{"create", "--phpmyadmin", "--xdebug=n"}, + }, + { + "inline value is left alone", + []string{"create", "--phpmyadmin=n"}, + []string{"create", "--phpmyadmin=n"}, + }, + { + // Node isOptionToken(): `arg !== '-' && arg.startsWith('-')`, so a + // bare dash IS a value. + "bare dash is a value, not an option", + []string{"create", "--phpmyadmin", "-"}, + []string{"create", "--phpmyadmin=-"}, + }, + { + "ordinary value flags are untouched", + []string{"create", "--slug", "Example", "-p", "n"}, + []string{"create", "--slug", "Example", "-p=n"}, + }, + { + "nothing past the -- terminator is rewritten", + []string{"create", "--", "--phpmyadmin", "n"}, + []string{"create", "--", "--phpmyadmin", "n"}, + }, + { + "DisableFlagParsing commands are passed through verbatim", + []string{"wp", "--phpmyadmin", "n"}, + []string{"wp", "--phpmyadmin", "n"}, + }, + { + "unresolvable command is passed through verbatim", + []string{"nope", "--phpmyadmin", "n"}, + []string{"nope", "--phpmyadmin", "n"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := NormalizeOptionalValues(testTree(), c.in) + if !slices.Equal(got, c.want) { + t.Errorf("NormalizeOptionalValues(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// The normalizer is only half the fix; the flag must also carry NoOptDefVal so +// the bare form means "enable" rather than "flag needs an argument". +func TestMarkOptionalValueSetsNoOptDefVal(t *testing.T) { + root := testTree() + leaf, _, err := root.Find([]string{"create"}) + if err != nil { + t.Fatal(err) + } + f := leaf.Flags().Lookup("phpmyadmin") + if f.NoOptDefVal != "y" { + t.Errorf("NoOptDefVal = %q, want \"y\"", f.NoOptDefVal) + } + if leaf.Flags().Lookup("slug").NoOptDefVal != "" { + t.Error("--slug must not become an optional-value flag") + } +} + +// End-to-end through cobra's own parser: this is the assertion that would still +// have passed with the old bool flags if it only exercised ProcessBooleanOption. +func TestOptionalValueParsesThroughCobra(t *testing.T) { + cases := []struct { + argv []string + want bool + }{ + {[]string{"create", "-p", "n"}, false}, + {[]string{"create", "-p", "no"}, false}, + {[]string{"create", "-p", "false"}, false}, + {[]string{"create", "-p", "0"}, false}, + {[]string{"create", "--phpmyadmin", "n"}, false}, + {[]string{"create", "--phpmyadmin=n"}, false}, + {[]string{"create", "-pn"}, false}, + {[]string{"create", "-p"}, true}, + {[]string{"create", "--phpmyadmin"}, true}, + {[]string{"create", "-p", "y"}, true}, + {[]string{"create", "--phpmyadmin=yes"}, true}, + {[]string{"create", "--phpmyadmin", "maybe"}, true}, // Node: not in FALSE_OPTIONS => true + } + for _, c := range cases { + root := testTree() + argv := NormalizeOptionalValues(root, c.argv) + leaf, rest, err := root.Find(argv) + if err != nil { + t.Fatalf("%q: find: %v", c.argv, err) + } + if err := leaf.ParseFlags(rest); err != nil { + t.Fatalf("%q: parse: %v", c.argv, err) + } + raw, _ := leaf.Flags().GetString("phpmyadmin") + if got := ProcessBooleanOption(raw); got != c.want { + t.Errorf("%q => raw %q => %v, want %v", c.argv, raw, got, c.want) + } + } +} diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 000000000..9a57057f3 --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,178 @@ +package output + +import ( + json "encoding/json/v2" + "fmt" + "io" + "reflect" + "sort" + "strconv" + "strings" +) + +func renderCSV(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + keys := make([]string, 0, len(v.Header)) + for k := range v.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if _, err := fmt.Fprintf(w, "# %s: %s\n", k, v.Header[k]); err != nil { + return err + } + } + return renderCSVRows(w, v.Data) + default: + return renderCSVRows(w, data) + } +} + +func renderCSVRows(w io.Writer, data any) error { + switch v := data.(type) { + case Rows: + return renderCSVMapRows(w, v) + case OrderedRows: + return renderCSVOrderedRows(w, v) + default: + return fmt.Errorf("CSV renderer requires Rows or OrderedRows, got %T", data) + } +} + +func renderCSVMapRows(w io.Writer, rows Rows) error { + if len(rows) == 0 { + return nil + } + + // Stable column order: sorted union of keys across rows. + colset := map[string]struct{}{} + for _, r := range rows { + for k := range r { + colset[k] = struct{}{} + } + } + cols := make([]string, 0, len(colset)) + for k := range colset { + cols = append(cols, k) + } + sort.Strings(cols) + + if err := writeCSVHeader(w, cols); err != nil { + return err + } + for _, r := range rows { + rec := make([]any, len(cols)) + for i, c := range cols { + if v, ok := r[c]; ok { + rec[i] = v + } + } + if err := writeCSVValues(w, rec); err != nil { + return err + } + } + return nil +} + +func renderCSVOrderedRows(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + return nil + } + + cols := rows.Columns() + if err := writeCSVHeader(w, cols); err != nil { + return err + } + for _, r := range rows { + rec := make([]any, len(cols)) + for i, c := range cols { + rec[i] = r.ValueAt(c) + } + if err := writeCSVValues(w, rec); err != nil { + return err + } + } + return nil +} + +func writeCSVHeader(w io.Writer, columns []string) error { + values := make([]string, len(columns)) + for i, column := range columns { + values[i] = quoteCSVString(HumanizeField(column)) + } + return writeCSVLine(w, values) +} + +func writeCSVValues(w io.Writer, values []any) error { + encoded := make([]string, len(values)) + for i, value := range values { + cell, err := encodeCSVValue(value) + if err != nil { + return fmt.Errorf("encode CSV value in column %d: %w", i, err) + } + encoded[i] = cell + } + return writeCSVLine(w, encoded) +} + +func writeCSVLine(w io.Writer, values []string) error { + _, err := io.WriteString(w, strings.Join(values, ",")+"\n") + return err +} + +func encodeCSVValue(value any) (string, error) { + if value == nil { + return "", nil + } + switch v := value.(type) { + case string: + return quoteCSVString(v), nil + case bool: + return strconv.FormatBool(v), nil + case int: + return strconv.FormatInt(int64(v), 10), nil + case int8: + return strconv.FormatInt(int64(v), 10), nil + case int16: + return strconv.FormatInt(int64(v), 10), nil + case int32: + return strconv.FormatInt(int64(v), 10), nil + case int64: + return strconv.FormatInt(v, 10), nil + case uint: + return strconv.FormatUint(uint64(v), 10), nil + case uint8: + return strconv.FormatUint(uint64(v), 10), nil + case uint16: + return strconv.FormatUint(uint64(v), 10), nil + case uint32: + return strconv.FormatUint(uint64(v), 10), nil + case uint64: + return strconv.FormatUint(v, 10), nil + case float32: + return strconv.FormatFloat(float64(v), 'g', -1, 32), nil + case float64: + return strconv.FormatFloat(v, 'g', -1, 64), nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "", nil + } + return encodeCSVValue(rv.Elem().Interface()) + } + if rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + return quoteCSVString(string(encoded)), nil + } + return quoteCSVString(fmt.Sprint(value)), nil +} + +func quoteCSVString(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/internal/output/fields.go b/internal/output/fields.go new file mode 100644 index 000000000..ad13fafe6 --- /dev/null +++ b/internal/output/fields.go @@ -0,0 +1,22 @@ +package output + +import ( + "strings" + "unicode" +) + +// HumanizeField mirrors the transform used by the Node CLI's formatData: +// key.split(/(?=[A-Z])/).join(' ').toLowerCase(). The split is deliberately +// ASCII-only, matching JavaScript's [A-Z] character class. +func HumanizeField(field string) string { + var humanized strings.Builder + first := true + for _, r := range field { + if !first && r >= 'A' && r <= 'Z' { + humanized.WriteByte(' ') + } + humanized.WriteRune(unicode.ToLower(r)) + first = false + } + return humanized.String() +} diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go new file mode 100644 index 000000000..4798c2f10 --- /dev/null +++ b/internal/output/fields_test.go @@ -0,0 +1,21 @@ +package output + +import "testing" + +func TestHumanizeFieldMatchesNodeCamelCaseSplit(t *testing.T) { + tests := map[string]string{ + "appId": "app id", + "appID": "app i d", + "currentCommit": "current commit", + "name": "name", + "Name": "name", + "ID": "i d", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := HumanizeField(input); got != want { + t.Fatalf("HumanizeField(%q) = %q, want %q", input, got, want) + } + }) + } +} diff --git a/internal/output/ids.go b/internal/output/ids.go new file mode 100644 index 000000000..8d4c9ca97 --- /dev/null +++ b/internal/output/ids.go @@ -0,0 +1,26 @@ +package output + +import ( + "fmt" + "io" + "strings" +) + +func renderIDs(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("ids renderer requires OrderedRows, got %T", data) + } + if len(rows) == 0 { + return nil + } + parts := make([]string, 0, len(rows)) + for _, r := range rows { + if len(r) == 0 { + continue + } + parts = append(parts, fmt.Sprint(r[0].Value)) + } + _, err := fmt.Fprintln(w, strings.Join(parts, " ")) + return err +} diff --git a/internal/output/ids_test.go b/internal/output/ids_test.go new file mode 100644 index 000000000..2c758d960 --- /dev/null +++ b/internal/output/ids_test.go @@ -0,0 +1,32 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderIDs(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "id", Value: "FOO"}}, + {{Key: "id", Value: "BAR"}}, + {{Key: "id", Value: "BAZ"}}, + } + if err := renderIDs(&buf, rows); err != nil { + t.Fatalf("renderIDs: %v", err) + } + want := "FOO BAR BAZ\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderIDsEmpty(t *testing.T) { + var buf bytes.Buffer + if err := renderIDs(&buf, OrderedRows{}); err != nil { + t.Fatalf("renderIDs: %v", err) + } + if buf.Len() != 0 { + t.Errorf("empty input must produce empty output; got %q", buf.String()) + } +} diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 000000000..8ab0329b8 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,100 @@ +package output + +import ( + "bytes" + "encoding/json/jsontext" + json "encoding/json/v2" + "fmt" + "io" +) + +// renderJSON writes data as tab-indented JSON via encoding/json/v2. +// Indent is "\t" to match Node's JSON.stringify(data, null, '\t') +// in src/lib/cli/format.ts. A trailing newline is appended to match +// Node's console.log behavior. +// +// HeaderData is rendered as just the data payload (the header is +// dropped) to match Node's command.js, where the keyValue header print +// is gated on `options.format !== 'json'` and then `res = res.data` +// runs unconditionally — so formatData never sees the header in JSON +// mode. +// +// OrderedRows is hand-emitted to preserve column insertion order; +// encoding/json/v2 would alphabetize map keys, matching Node's +// JSON.stringify(arrayOfObjects) insertion-order behavior. +func renderJSON(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + // Node parity: drop header in JSON mode; emit only the data payload. + return renderJSON(w, v.Data) + case OrderedRows: + if err := writeOrderedRowsJSON(w, v); err != nil { + return err + } + default: + opts := []json.Options{ + json.Deterministic(true), + jsontext.WithIndent("\t"), + } + if err := json.MarshalWrite(w, data, opts...); err != nil { + return err + } + } + _, err := io.WriteString(w, "\n") + return err +} + +// writeOrderedRowsJSON hand-emits OrderedRows as a JSON array of +// objects with insertion-ordered keys, matching Node's +// JSON.stringify(arrayOfObjects, null, '\t') output. +// +// We hand-roll because encoding/json/v2 sorts map keys alphabetically, +// and a slice of Cell structs would marshal as +// [[{"Key":..., "Value":...}, ...], ...] — wrong shape entirely. +func writeOrderedRowsJSON(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + _, err := io.WriteString(w, "[]") + return err + } + + var buf bytes.Buffer + buf.WriteString("[\n") + for i, row := range rows { + buf.WriteString("\t{") + if len(row) > 0 { + buf.WriteByte('\n') + } + for j, cell := range row { + keyJSON, err := json.Marshal(cell.Key) + if err != nil { + return err + } + valJSON, err := json.Marshal(cell.Value) + if err != nil { + return err + } + buf.WriteString("\t\t") + buf.Write(keyJSON) + buf.WriteString(": ") + buf.Write(valJSON) + if j < len(row)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + if len(row) > 0 { + buf.WriteString("\t") + } + buf.WriteByte('}') + if i < len(rows)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + buf.WriteByte(']') + + if _, err := w.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write OrderedRows JSON: %w", err) + } + return nil +} diff --git a/internal/output/keyvalue.go b/internal/output/keyvalue.go new file mode 100644 index 000000000..410ace498 --- /dev/null +++ b/internal/output/keyvalue.go @@ -0,0 +1,35 @@ +package output + +import ( + "fmt" + "io" +) + +// renderKeyValue handles two row shapes: +// - single-column: {Key: "MY_VAR", Value: "1"} -> "MY_VAR=1" +// - two-column with literal headers: {key: MY_VAR, value: 1} -> "MY_VAR=1" +// +// The two-column form is how envvar get-all formats output when --format=keyValue. +func renderKeyValue(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("keyValue renderer requires OrderedRows, got %T", data) + } + for _, r := range rows { + k, v := pickKeyValuePair(r) + if _, err := fmt.Fprintf(w, "%v=%v\n", k, v); err != nil { + return err + } + } + return nil +} + +func pickKeyValuePair(r OrderedRow) (any, any) { + if len(r) == 2 && r[0].Key == "key" && r[1].Key == "value" { + return r[0].Value, r[1].Value + } + if len(r) >= 1 { + return r[0].Key, r[0].Value + } + return "", "" +} diff --git a/internal/output/keyvalue_block.go b/internal/output/keyvalue_block.go new file mode 100644 index 000000000..e39c12e6e --- /dev/null +++ b/internal/output/keyvalue_block.go @@ -0,0 +1,77 @@ +package output + +import ( + "bytes" + "strings" + + "github.com/fatih/color" +) + +// Tuple is Node's `Tuple` from src/lib/cli/format.ts — a key/value pair fed +// to keyValue(). Distinct from the OrderedRow/Cell shapes used by --format +// rendering: this one is the confirmation info-table payload. +type Tuple struct { + Key string + Value string +} + +// keyValueRule is Node's literal separator line (format.ts:116,130) — 35 '='. +const keyValueRule = "===================================" + +// KeyValue ports keyValue() from src/lib/cli/format.ts. +// +// =================================== +// + App: my-app (id: 42) +// + Environment: develop (id: 7) +// =================================== +// +// Two Node details that are easy to get wrong and are pinned by tests: +// - the OPENING rule is emitted only when there is at least one pair, but +// the CLOSING rule is unconditional, so an empty list is a single rule; +// - a row whose key is "environment" (case-insensitive) has its ENTIRE +// value run through FormatEnvironment, which lowercases it. The confirm +// table's value is "production (id: 1)", not "production", so it never +// takes formatEnvironment's red/uppercase production branch. +// +// The returned string has no trailing newline (Node joins with '\n' and the +// caller console.logs it). +func KeyValue(values []Tuple) string { + lines := make([]string, 0, len(values)+2) + if len(values) > 0 { + lines = append(lines, keyValueRule) + } + for _, v := range values { + formatted := v.Value + if strings.EqualFold(v.Key, "environment") { + formatted = FormatEnvironment(v.Value) + } + lines = append(lines, "+ "+v.Key+": "+formatted) + } + lines = append(lines, keyValueRule) + return strings.Join(lines, "\n") +} + +// FormatEnvironment ports formatEnvironment() from src/lib/cli/format.ts: +// an exact (case-insensitive) "production" renders red + UPPERCASED, +// anything else renders bright-blue + lowercased. NO_COLOR and non-TTY +// stdout are honored by fatih/color, matching chalk. +func FormatEnvironment(environment string) string { + if strings.EqualFold(environment, "production") { + return color.RedString(strings.ToUpper(environment)) + } + return color.HiBlueString(strings.ToLower(environment)) +} + +// TableString renders rows the way Node's formatData(rows, 'table') does and +// returns the result as a string: empty for no rows, and no trailing newline. +// Used for the `Replacements` cell inside a KeyValue info table. +func TableString(rows OrderedRows) string { + if len(rows) == 0 { + return "" + } + var buf bytes.Buffer + if err := renderTable(&buf, rows); err != nil { + return "" + } + return strings.TrimRight(buf.String(), "\n") +} diff --git a/internal/output/keyvalue_block_test.go b/internal/output/keyvalue_block_test.go new file mode 100644 index 000000000..89fb31e72 --- /dev/null +++ b/internal/output/keyvalue_block_test.go @@ -0,0 +1,95 @@ +package output + +import ( + "regexp" + "testing" +) + +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +// The expected strings below were captured from the shipping Node CLI: +// +// node -e "const {keyValue}=require('./dist/lib/cli/format.js'); +// console.log(JSON.stringify(keyValue([...])))" +// +// KeyValue is the port of src/lib/cli/format.ts keyValue(). It is what +// src/lib/cli/prompt.ts confirm() console.logs above every requireConfirm +// yes/no prompt. + +func TestKeyValueBlockMatchesNode(t *testing.T) { + got := KeyValue([]Tuple{ + {Key: "App", Value: "my-app (id: 42)"}, + {Key: "Environment", Value: "develop (id: 7)"}, + }) + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// Node pushes the opening rule only when there is at least one pair, but +// always pushes the closing rule — so an empty list renders as a single +// 35-character rule (format.ts:112-132). +func TestKeyValueBlockEmptyIsSingleRule(t *testing.T) { + got := KeyValue(nil) + want := "===================================" + if got != want { + t.Errorf("KeyValue(nil) = %q, want %q", got, want) + } +} + +// keyValue() special-cases the literal key "environment" (case-insensitive) +// and runs the WHOLE value through formatEnvironment, which lowercases it. +// "Develop (id: 7)" therefore renders as "develop (id: 7)". +func TestKeyValueBlockLowercasesEnvironmentValue(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := KeyValue([]Tuple{{Key: "Environment", Value: "Develop (id: 7)"}}) + want := "===================================\n" + + "+ Environment: develop (id: 7)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// formatEnvironment only reddens+uppercases when the ENTIRE value equals +// "production". The confirm table's Environment value is "production (id: 1)", +// which does not match, so it stays lowercase like any other env. +func TestKeyValueBlockProductionRowIsNotUppercased(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := KeyValue([]Tuple{{Key: "Environment", Value: "production (id: 1)"}}) + want := "===================================\n" + + "+ Environment: production (id: 1)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// Node's table for the sync `Replacements` / import-sql `Replacements` rows +// comes from formatData(rows, 'table'), which returns the empty string for +// an empty slice and otherwise has NO trailing newline. +func TestNodeTableStringHasNoTrailingNewline(t *testing.T) { + got := TableString(OrderedRows{ + {{Key: "from", Value: "a.com"}, {Key: "to", Value: "b.com"}}, + }) + want := "┌───────┬───────┐\n" + + "│ from │ to │\n" + + "├───────┼───────┤\n" + + "│ a.com │ b.com │\n" + + "└───────┴───────┘" + if stripANSI(got) != want { + t.Errorf("TableString mismatch\n got: %q\nwant: %q", stripANSI(got), want) + } +} + +func TestNodeTableStringEmptyIsEmpty(t *testing.T) { + if got := TableString(OrderedRows{}); got != "" { + t.Errorf("TableString(empty) = %q, want \"\"", got) + } +} diff --git a/internal/output/keyvalue_test.go b/internal/output/keyvalue_test.go new file mode 100644 index 000000000..a8798a872 --- /dev/null +++ b/internal/output/keyvalue_test.go @@ -0,0 +1,35 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderKeyValue(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "MY_VAR", Value: "1"}}, + {{Key: "OTHER_VAR", Value: "two"}}, + } + if err := renderKeyValue(&buf, rows); err != nil { + t.Fatalf("renderKeyValue: %v", err) + } + want := "MY_VAR=1\nOTHER_VAR=two\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderKeyValueTwoColumns(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "key", Value: "MY_VAR"}, {Key: "value", Value: "1"}}, + } + if err := renderKeyValue(&buf, rows); err != nil { + t.Fatalf("renderKeyValue: %v", err) + } + want := "MY_VAR=1\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} diff --git a/internal/output/ordered.go b/internal/output/ordered.go new file mode 100644 index 000000000..6138184a3 --- /dev/null +++ b/internal/output/ordered.go @@ -0,0 +1,44 @@ +// internal/output/ordered.go +package output + +// Cell is one column of an OrderedRow. +type Cell struct { + Key string + Value any +} + +// OrderedRow is a column-ordered alternative to map[string]any. Used by +// commands whose JSON / CSV / text output must match Node's insertion order +// (Go's map iteration is randomized). +type OrderedRow []Cell + +// Keys returns the keys in insertion order. +func (r OrderedRow) Keys() []string { + out := make([]string, len(r)) + for i, c := range r { + out[i] = c.Key + } + return out +} + +// ValueAt returns the value for key, or nil if absent. +func (r OrderedRow) ValueAt(key string) any { + for _, c := range r { + if c.Key == key { + return c.Value + } + } + return nil +} + +// OrderedRows is a slice of OrderedRow. +type OrderedRows []OrderedRow + +// Columns returns the column key order taken from the first row. +// Empty OrderedRows returns nil. +func (rs OrderedRows) Columns() []string { + if len(rs) == 0 { + return nil + } + return rs[0].Keys() +} diff --git a/internal/output/ordered_test.go b/internal/output/ordered_test.go new file mode 100644 index 000000000..4553823e7 --- /dev/null +++ b/internal/output/ordered_test.go @@ -0,0 +1,42 @@ +// internal/output/ordered_test.go +package output + +import ( + "reflect" + "testing" +) + +func TestOrderedRowKeysInOrder(t *testing.T) { + r := OrderedRow{ + {Key: "id", Value: 42}, + {Key: "name", Value: "x"}, + {Key: "repo", Value: "wpcomvip/x"}, + } + got := r.Keys() + want := []string{"id", "name", "repo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Keys() = %v, want %v", got, want) + } +} + +func TestOrderedRowValueAt(t *testing.T) { + r := OrderedRow{{Key: "k", Value: "v"}} + if r.ValueAt("k") != "v" { + t.Errorf("ValueAt(k) = %v, want v", r.ValueAt("k")) + } + if r.ValueAt("missing") != nil { + t.Errorf("ValueAt(missing) = %v, want nil", r.ValueAt("missing")) + } +} + +func TestOrderedRowsAllKeysFromFirst(t *testing.T) { + rs := OrderedRows{ + {{Key: "a", Value: 1}, {Key: "b", Value: 2}}, + {{Key: "a", Value: 3}, {Key: "b", Value: 4}}, + } + got := rs.Columns() + want := []string{"a", "b"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Columns() = %v, want %v", got, want) + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 000000000..717a15081 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,62 @@ +// Package output renders command results in table, CSV, or JSON. +// +// Handlers return one of: +// - HeaderData{Header, Data} — printed as a key:value block followed by formatted data +// - Rows — printed as table/csv/json +// - nil — no output +// +// The format is selected by --format on commands that opt in via the +// WithFormat middleware. See spec §6.1. +package output + +import ( + "fmt" + "io" +) + +// Format is the output format requested by --format. +type Format string + +const ( + FormatTable Format = "table" + FormatCSV Format = "csv" + FormatJSON Format = "json" + FormatText Format = "text" + FormatKeyValue Format = "keyValue" + FormatIDs Format = "ids" +) + +// Rows is a slice of string-keyed maps, the primary tabular return type from +// command handlers. +type Rows []map[string]any + +// HeaderData wraps a key/value header section and a data payload. The header +// is rendered above the data (table/csv) or as "__header" (json). +type HeaderData struct { + Header map[string]string + Data any +} + +// Render dispatches data to the appropriate renderer for format f. +// If data is nil, Render returns immediately without writing anything. +func Render(w io.Writer, f Format, data any) error { + if data == nil { + return nil + } + switch f { + case FormatJSON: + return renderJSON(w, data) + case FormatCSV: + return renderCSV(w, data) + case FormatText: + return renderText(w, data) + case FormatKeyValue: + return renderKeyValue(w, data) + case FormatIDs: + return renderIDs(w, data) + case FormatTable, "": + return renderTable(w, data) + default: + return fmt.Errorf("unknown output format %q (want table, csv, json, text, keyValue, or ids)", f) + } +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 000000000..bfe6d7a23 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,382 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestRenderJSONRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, `"name": "alpha"`) || !strings.Contains(got, `"name": "beta"`) { + t.Errorf("missing expected entries in JSON output: %q", got) + } +} + +func TestRenderJSONHeaderData(t *testing.T) { + // Node parity: in JSON mode, command.js drops res.header entirely and + // only emits res.data. See src/lib/cli/command.js — the keyValue header + // print is gated on `options.format !== 'json'`, then `res = res.data` + // runs unconditionally. So formatData never sees the header in JSON mode. + data := HeaderData{ + Header: map[string]string{"app": "my-site"}, + Data: Rows{{"id": 1}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if strings.Contains(got, "__header") || strings.Contains(got, `"header"`) || + strings.Contains(got, "my-site") { + t.Errorf("JSON HeaderData must drop header for Node parity; got:\n%s", got) + } + if !strings.Contains(got, `"id": 1`) { + t.Errorf("JSON HeaderData must emit data payload; got:\n%s", got) + } +} + +func TestRenderJSONNilNoOutput(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, nil); err != nil { + t.Fatalf("Render: %v", err) + } + if buf.Len() != 0 { + t.Errorf("nil data must produce no output, got %q", buf.String()) + } +} + +func TestRenderRejectsUnknownFormat(t *testing.T) { + var buf bytes.Buffer + err := Render(&buf, Format("xml"), Rows{{"id": 1}}) + if err == nil { + t.Fatal("expected error for unknown format") + } +} + +func TestRenderJSONHasTrailingNewline(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, Rows{{"id": 1}}); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if len(got) == 0 || got[len(got)-1] != '\n' { + t.Errorf("JSON output must end with newline; got: %q", got) + } +} + +func TestRenderCSVRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatCSV, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + wantHeader := `"id","name"` + if !strings.HasPrefix(got, wantHeader) { + t.Errorf("CSV must start with sorted header %q, got %q", wantHeader, got) + } + if !strings.Contains(got, `1,"alpha"`) || !strings.Contains(got, `2,"beta"`) { + t.Errorf("CSV output missing rows: %q", got) + } +} + +func TestRenderCSVMatchesNodeTypedQuoting(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + { + {Key: "appId", Value: 7}, + {Key: "name", Value: "alpha"}, + {Key: "active", Value: true}, + {Key: "empty", Value: nil}, + }, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"app id\",\"name\",\"active\",\"empty\"\n7,\"alpha\",true,\n" + if got := buf.String(); got != want { + t.Fatalf("csv = %q, want %q", got, want) + } +} + +func TestRenderCSVEscapesQuotesLikeJSON2CSV(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "value", Value: "a\"b"}}, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"value\"\n\"a\"\"b\"\n" + if got := buf.String(); got != want { + t.Fatalf("csv = %q, want %q", got, want) + } +} + +func TestRenderCSVHeaderDataPrintsHeaderLines(t *testing.T) { + data := HeaderData{ + Header: map[string]string{"app": "my-site", "env": "staging"}, + Data: Rows{{"id": 1, "name": "alpha"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatCSV, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, "# app: my-site") { + t.Errorf("HeaderData CSV missing header comment for app: %q", got) + } + if !strings.Contains(got, "# env: staging") { + t.Errorf("HeaderData CSV missing header comment for env: %q", got) + } +} + +func TestRenderTableRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + for _, want := range []string{"id", "name", "alpha", "beta"} { + if !strings.Contains(got, want) { + t.Errorf("table output missing %q:\n%s", want, got) + } + } +} + +// A bytes.Buffer is not a terminal, so this pins the shape a redirect, a pipe, +// a cron job or `docker exec` sees. Node clears cli-table3's head and border +// styles in exactly that situation (src/bin/vip-logs.js:171-172, and via the +// colour layer's own TTY detection for src/lib/cli/format.ts `table()`), so +// there are no escape bytes anywhere in the frame. +func TestRenderTableOrderedRowsNonTTYMatchesNodeCLI(t *testing.T) { + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "appId", Value: 1}, {Key: "name", Value: "alpha"}}, + {{Key: "id", Value: 20}, {Key: "appId", Value: 20}, {Key: "name", Value: "beta"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, rows); err != nil { + t.Fatal(err) + } + want := "┌────┬────────┬───────┐\n" + + "│ id │ app id │ name │\n" + + "├────┼────────┼───────┤\n" + + "│ 1 │ 1 │ alpha │\n" + + "├────┼────────┼───────┤\n" + + "│ 20 │ 20 │ beta │\n" + + "└────┴────────┴───────┘\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } + if strings.Contains(buf.String(), "\x1b[") { + t.Errorf("non-TTY table carries ANSI:\n%q", buf.String()) + } +} + +// The terminal shape is unchanged: grey borders, bright-blue head. +func TestRenderTableOrderedRowsTTYMatchesNodeCLI(t *testing.T) { + headers := []string{"id", "app id", "name"} + rows := [][]string{{"1", "1", "alpha"}, {"20", "20", "beta"}} + + var buf bytes.Buffer + if err := renderNodeTableStyled(&buf, headers, rows, 0, true); err != nil { + t.Fatal(err) + } + want := "\x1b[90m┌────\x1b[39m\x1b[90m┬────────\x1b[39m\x1b[90m┬───────┐\x1b[39m\n" + + "\x1b[90m│\x1b[39m\x1b[94m id \x1b[39m\x1b[90m│\x1b[39m\x1b[94m app id \x1b[39m\x1b[90m│\x1b[39m\x1b[94m name \x1b[39m\x1b[90m│\x1b[39m\n" + + "\x1b[90m├────\x1b[39m\x1b[90m┼────────\x1b[39m\x1b[90m┼───────┤\x1b[39m\n" + + "\x1b[90m│\x1b[39m 1 \x1b[90m│\x1b[39m 1 \x1b[90m│\x1b[39m alpha \x1b[90m│\x1b[39m\n" + + "\x1b[90m├────\x1b[39m\x1b[90m┼────────\x1b[39m\x1b[90m┼───────┤\x1b[39m\n" + + "\x1b[90m│\x1b[39m 20 \x1b[90m│\x1b[39m 20 \x1b[90m│\x1b[39m beta \x1b[90m│\x1b[39m\n" + + "\x1b[90m└────\x1b[39m\x1b[90m┴────────\x1b[39m\x1b[90m┴───────┘\x1b[39m\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } +} + +// Clearing the head/border styles must not touch ANSI that came in with the +// DATA — Node clears styles, it does not strip cells. +func TestRenderTableMultilineAndANSIMatchesNodeCLI(t *testing.T) { + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "value", Value: "a\nb"}}, + {{Key: "id", Value: 2}, {Key: "value", Value: "\x1b[31mred\x1b[39m"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, rows); err != nil { + t.Fatal(err) + } + want := "┌────┬───────┐\n" + + "│ id │ value │\n" + + "├────┼───────┤\n" + + "│ 1 │ a │\n" + + "│ │ b │\n" + + "├────┼───────┤\n" + + "│ 2 │ \x1b[31mred\x1b[39m │\n" + + "└────┴───────┘\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } +} + +func TestRenderTableAtWidthWrapsLongLogMessageWithoutWrappingBorders(t *testing.T) { + headers := []string{"timestamp", "message"} + message := "PHP message: [ERROR] Permission denied for MCP API access. User ID 0 does not have capability read." + rows := [][]string{{"2026-07-15T07:17:38.002797318Z", message}} + + var buf bytes.Buffer + if err := renderNodeTableAtWidth(&buf, headers, rows, 78); err != nil { + t.Fatal(err) + } + for lineNumber, line := range strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") { + if width := nodeDisplayWidth(line); width > 78 { + t.Fatalf("line %d width = %d, want <= 78: %q", lineNumber+1, width, line) + } + } + for _, word := range strings.Fields(message) { + if !strings.Contains(stripNodeANSI(buf.String()), word) { + t.Fatalf("rendered table lost message word %q:\n%s", word, buf.String()) + } + } +} + +func TestRenderNodeTableNonTTYKeepsNaturalWidth(t *testing.T) { + headers := []string{"timestamp", "message"} + rows := [][]string{{"2026-07-15T07:17:38.002797318Z", strings.Repeat("wide ", 30)}} + + var buf bytes.Buffer + if err := renderNodeTable(&buf, headers, rows); err != nil { + t.Fatal(err) + } + if width := nodeDisplayWidth(strings.Split(buf.String(), "\n")[0]); width <= 78 { + t.Fatalf("non-TTY natural table width = %d, want > 78", width) + } +} + +func TestRenderNodeTableNonTTYDoesNotRewriteANSIStateAcrossExplicitLines(t *testing.T) { + headers := []string{"value"} + rows := [][]string{{"\x1b[31malpha\nbeta\x1b[39m"}} + + var buf bytes.Buffer + if err := renderNodeTable(&buf, headers, rows); err != nil { + t.Fatal(err) + } + if got := strings.Count(buf.String(), "\x1b[31m"); got != 1 { + t.Fatalf("non-TTY renderer wrote red foreground %d times, want original byte sequence once: %q", got, buf.String()) + } +} + +func TestNodeDisplayWidthMatchesNodeStripANSICompatibility(t *testing.T) { + tests := map[string]struct { + value string + want int + }{ + "SGR color": { + value: "\x1b[31mred\x1b[39m", + want: 3, + }, + "OSC hyperlink with ansi-regex v5 behavior": { + value: "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\", + want: 26, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if got := nodeDisplayWidth(test.value); got != test.want { + t.Fatalf("nodeDisplayWidth() = %d, want %d", got, test.want) + } + }) + } +} + +func TestRenderTableHeaderDataPrintsHeaderBlock(t *testing.T) { + data := HeaderData{ + Header: map[string]string{"app": "my-site"}, + Data: Rows{{"id": 1}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, "app: my-site") { + t.Errorf("header block missing: %q", got) + } + if !strings.Contains(got, "id") { + t.Errorf("table missing after header block: %q", got) + } +} + +func TestRenderTableHeaderDataWithOrderedRows(t *testing.T) { + var buf bytes.Buffer + hd := HeaderData{ + Header: map[string]string{"id": "42", "name": "myapp"}, + Data: OrderedRows{ + {{Key: "envid", Value: 7}, {Key: "envname", Value: "develop"}}, + }, + } + if err := Render(&buf, FormatTable, hd); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + for _, want := range []string{"id: 42", "name: myapp", "develop"} { + if !strings.Contains(got, want) { + t.Errorf("table HeaderData output missing %q in:\n%s", want, got) + } + } +} + +func TestRenderJSONOrderedRowsPreservesKeyOrder(t *testing.T) { + // Node parity: JSON.stringify of an array of objects emits objects with + // keys in insertion order, e.g. [{"zeta":1,"alpha":2}]. The Cell struct + // shape ({"Key":..., "Value":...}) must NOT leak into output. + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "zeta", Value: 1}, {Key: "alpha", Value: 2}}, + } + if err := Render(&buf, FormatJSON, rows); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if strings.Contains(got, `"Key"`) || strings.Contains(got, `"Value"`) { + t.Fatalf("Cell struct fields must not leak into JSON; got:\n%s", got) + } + zetaIdx := strings.Index(got, `"zeta"`) + alphaIdx := strings.Index(got, `"alpha"`) + if zetaIdx < 0 || alphaIdx < 0 { + t.Fatalf("missing keys in output: %q", got) + } + if zetaIdx > alphaIdx { + t.Errorf("OrderedRows must preserve insertion order; got:\n%s", got) + } +} + +func TestRenderCSVOrderedRows(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "name", Value: "a"}}, + {{Key: "id", Value: 2}, {Key: "name", Value: "b"}}, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"id\",\"name\"\n1,\"a\"\n2,\"b\"\n" + if buf.String() != want { + t.Errorf("csv OrderedRows = %q, want %q", buf.String(), want) + } +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 000000000..31459c109 --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,231 @@ +package output + +import ( + "fmt" + "io" + "sort" + "strings" +) + +const ( + ansiGray = "\x1b[90m" + ansiBrightBlue = "\x1b[94m" + ansiFgClose = "\x1b[39m" +) + +func renderTable(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + keys := make([]string, 0, len(v.Header)) + for k := range v.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if _, err := fmt.Fprintf(w, "%s: %s\n", k, v.Header[k]); err != nil { + return err + } + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + return renderTableRows(w, v.Data) + default: + return renderTableRows(w, data) + } +} + +func renderTableRows(w io.Writer, data any) error { + switch v := data.(type) { + case Rows: + return renderTableMapRows(w, v) + case OrderedRows: + return renderTableOrderedRows(w, v) + default: + return fmt.Errorf("table renderer requires Rows or OrderedRows, got %T", data) + } +} + +func renderTableMapRows(w io.Writer, rows Rows) error { + if len(rows) == 0 { + return nil + } + + colset := map[string]struct{}{} + for _, r := range rows { + for k := range r { + colset[k] = struct{}{} + } + } + cols := make([]string, 0, len(colset)) + for k := range colset { + cols = append(cols, k) + } + sort.Strings(cols) + + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = HumanizeField(c) + } + values := make([][]string, len(rows)) + for rowIndex, r := range rows { + values[rowIndex] = make([]string, len(cols)) + for columnIndex, c := range cols { + if v, ok := r[c]; ok { + values[rowIndex][columnIndex] = nodeCell(v) + } + } + } + return renderNodeTable(w, headers, values) +} + +func renderTableOrderedRows(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + return nil + } + + cols := rows.Columns() + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = HumanizeField(c) + } + values := make([][]string, len(rows)) + for rowIndex, r := range rows { + values[rowIndex] = make([]string, len(cols)) + for i, c := range cols { + values[rowIndex][i] = nodeCell(r.ValueAt(c)) + } + } + return renderNodeTable(w, headers, values) +} + +func nodeCell(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func renderNodeTable(w io.Writer, headers []string, rows [][]string) error { + return renderNodeTableStyled(w, headers, rows, terminalTableWidth(w), terminalTableIsTTY(w)) +} + +// renderNodeTableAtWidth renders at an explicit width with colour ON. It is +// the shape a real terminal gets, and exists so tests can pin that shape +// without a pty. +func renderNodeTableAtWidth(w io.Writer, headers []string, rows [][]string, maxTableWidth int) error { + return renderNodeTableStyled(w, headers, rows, maxTableWidth, true) +} + +// renderNodeTableStyled is the single renderer. `colorize` corresponds to +// cli-table3's style.head/style.border being populated, which the Node CLI +// only does when stdout is a TTY — see terminalTableIsTTY. +// +// Note the cells themselves are NOT stripped: a value that already carries +// ANSI (a coloured environment name, say) keeps it, exactly as it would in +// Node, where only the head/border STYLES are cleared. +func renderNodeTableStyled(w io.Writer, headers []string, rows [][]string, maxTableWidth int, colorize bool) error { + if len(headers) == 0 { + return nil + } + wrapCells := maxTableWidth > 0 && nodeTableWidth(nodeNaturalColumnWidths(headers, rows)) > maxTableWidth + widths := nodeColumnWidths(headers, rows, maxTableWidth) + if err := writeNodeBorder(w, "┌", "┬", "┐", widths, colorize); err != nil { + return err + } + if err := writeNodeRow(w, headers, widths, true, wrapCells, colorize); err != nil { + return err + } + if err := writeNodeBorder(w, "├", "┼", "┤", widths, colorize); err != nil { + return err + } + for i, row := range rows { + if err := writeNodeRow(w, row, widths, false, wrapCells, colorize); err != nil { + return err + } + if i < len(rows)-1 { + if err := writeNodeBorder(w, "├", "┼", "┤", widths, colorize); err != nil { + return err + } + } + } + return writeNodeBorder(w, "└", "┴", "┘", widths, colorize) +} + +func writeNodeBorder(w io.Writer, left, middle, right string, widths []int, colorize bool) error { + for i, width := range widths { + start := middle + if i == 0 { + start = left + } + end := "" + if i == len(widths)-1 { + end = right + } + segment := start + strings.Repeat("─", width+2) + end + if _, err := io.WriteString(w, colorText(ansiGray, segment, colorize)); err != nil { + return err + } + } + _, err := io.WriteString(w, "\n") + return err +} + +func writeNodeRow(w io.Writer, values []string, widths []int, header, wrapCells, colorize bool) error { + lines := make([][]string, len(values)) + height := 1 + for i, value := range values { + if wrapCells { + lines[i] = wrapNodeCell(value, widths[i]) + } else { + lines[i] = strings.Split(value, "\n") + } + if len(lines[i]) > height { + height = len(lines[i]) + } + } + for lineIndex := 0; lineIndex < height; lineIndex++ { + physical := make([]string, len(values)) + for columnIndex := range values { + if lineIndex < len(lines[columnIndex]) { + physical[columnIndex] = lines[columnIndex][lineIndex] + } + } + if err := writeNodePhysicalRow(w, physical, widths, header, colorize); err != nil { + return err + } + } + return nil +} + +func writeNodePhysicalRow(w io.Writer, values []string, widths []int, header, colorize bool) error { + for i, value := range values { + if _, err := io.WriteString(w, colorText(ansiGray, "│", colorize)); err != nil { + return err + } + cell := padNodeCell(value, widths[i]) + if header { + cell = colorText(ansiBrightBlue, cell, colorize) + } + if _, err := io.WriteString(w, cell); err != nil { + return err + } + } + _, err := io.WriteString(w, colorText(ansiGray, "│", colorize)+"\n") + return err +} + +func padNodeCell(value string, width int) string { + return " " + value + strings.Repeat(" ", width-nodeDisplayWidth(value)+1) +} + +// colorText wraps value in an SGR pair, or returns it untouched when the +// destination is not a terminal. Returning the bare string (rather than an +// empty escape pair) matters: the differential compares byte-for-byte against +// Node, whose cleared style produces no escape bytes at all. +func colorText(open, value string, colorize bool) string { + if !colorize { + return value + } + return open + value + ansiFgClose +} diff --git a/internal/output/table_layout.go b/internal/output/table_layout.go new file mode 100644 index 000000000..ad7bc4505 --- /dev/null +++ b/internal/output/table_layout.go @@ -0,0 +1,472 @@ +package output + +import ( + "io" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +const terminalTableSafetyMargin = 2 + +// nodeANSIRegexp is the Go equivalent of ansi-regex v5.0.1, which is what +// cli-table3 reaches through string-width in the Node CLI. Keeping that exact +// compatibility includes its historical OSC handling quirks. +var nodeANSIRegexp = regexp.MustCompile( + `[\x1B\x{009B}][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\\/#&.:=?%@~_]*)*)?\x07)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))`, +) + +var nodeSGRRegexp = regexp.MustCompile(`\x1b\[([0-9;]*)m`) + +var nodeStyleCodes = []int{1, 2, 3, 4, 5, 7, 8, 9} + +var nodeStyleCloseCodes = map[int]string{ + 1: "\x1b[22m", + 2: "\x1b[22m", + 3: "\x1b[23m", + 4: "\x1b[24m", + 5: "\x1b[25m", + 7: "\x1b[27m", + 8: "\x1b[28m", + 9: "\x1b[29m", +} + +type nodeWrapToken struct { + raw string + width int + whitespace bool +} + +type nodeSGRState struct { + foreground string + background string + styles map[int]string +} + +type nodeFDWriter interface { + Fd() uintptr +} + +// terminalTableIsTTY reports whether w is an interactive terminal. +// +// It is the Go equivalent of Node's `process.stdout.isTTY`, and it gates BOTH +// of the render-time decisions cli-table3 makes for the Node CLI: +// +// - column widths (see terminalTableWidth below), and +// - whether the table is colourised at all. +// +// The colour half was missing, and that was a real regression: `vip logs` +// under cron, systemd, ssh without a tty, `docker exec`, or any `>file` +// redirect got escape sequences Node would not have written. Node's +// src/bin/vip-logs.js:162-172 says so explicitly — +// +// if ( process.stdout.isTTY && process.stdout.columns ) { +// options.colWidths = [ ... ]; +// } else { +// options.style.head = []; +// options.style.border = []; +// } +// +// and every other table goes through src/lib/cli/format.ts `table()`, which +// asks for `style.head = [ 'brightBlue' ]` and lets cli-table3's colour layer +// decide: that layer disables itself when stdout is not a TTY, so those tables +// come out plain too. One predicate therefore covers both surfaces. +func terminalTableIsTTY(w io.Writer) bool { + f, ok := w.(nodeFDWriter) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} + +func terminalTableWidth(w io.Writer) int { + f, ok := w.(nodeFDWriter) + if !ok { + return 0 + } + fd := int(f.Fd()) + if !term.IsTerminal(fd) { + return 0 + } + cols, _, err := term.GetSize(fd) + if err != nil || cols <= terminalTableSafetyMargin { + return 0 + } + return cols - terminalTableSafetyMargin +} + +func nodeColumnWidths(headers []string, rows [][]string, maxTableWidth int) []int { + widths := nodeNaturalColumnWidths(headers, rows) + if maxTableWidth <= 0 || nodeTableWidth(widths) <= maxTableWidth { + return widths + } + for i := range widths { + if widths[i] < 1 { + widths[i] = 1 + } + } + + budget := maxTableWidth - nodeTableOverhead(len(widths)) + if budget < len(widths) { + budget = len(widths) + } + + preferred := make([]int, len(headers)) + for i, header := range headers { + preferred[i] = nodeHeaderMinimumWidth(header) + if preferred[i] > widths[i] { + preferred[i] = widths[i] + } + } + shrinkNodeWidths(widths, preferred, budget) + + ones := make([]int, len(widths)) + for i := range ones { + ones[i] = 1 + } + shrinkNodeWidths(widths, ones, budget) + return widths +} + +func nodeNaturalColumnWidths(headers []string, rows [][]string) []int { + widths := make([]int, len(headers)) + for i, header := range headers { + widths[i] = nodeDisplayWidth(header) + } + for _, row := range rows { + for i, value := range row { + if i >= len(widths) { + break + } + for _, line := range strings.Split(value, "\n") { + if width := nodeDisplayWidth(line); width > widths[i] { + widths[i] = width + } + } + } + } + return widths +} + +func nodeHeaderMinimumWidth(header string) int { + minimum := 1 + for _, word := range strings.Fields(stripNodeANSI(header)) { + if width := runewidth.StringWidth(word); width > minimum { + minimum = width + } + } + return minimum +} + +func shrinkNodeWidths(widths, minimums []int, budget int) { + total := sumNodeWidths(widths) + for total > budget { + widest := 0 + for i, width := range widths { + if width > minimums[i] && width > widest { + widest = width + } + } + if widest == 0 { + return + } + for i := range widths { + if total <= budget { + return + } + if widths[i] == widest && widths[i] > minimums[i] { + widths[i]-- + total-- + } + } + } +} + +func sumNodeWidths(widths []int) int { + total := 0 + for _, width := range widths { + total += width + } + return total +} + +func nodeTableOverhead(columns int) int { + return 3*columns + 1 +} + +func nodeTableWidth(widths []int) int { + return sumNodeWidths(widths) + nodeTableOverhead(len(widths)) +} + +func nodeDisplayWidth(value string) int { + return runewidth.StringWidth(stripNodeANSI(value)) +} + +func stripNodeANSI(value string) string { + return nodeANSIRegexp.ReplaceAllString(value, "") +} + +func wrapNodeCell(value string, width int) []string { + if width < 1 { + width = 1 + } + + var lines []string + for _, logicalLine := range strings.Split(value, "\n") { + lines = append(lines, wrapNodeLogicalLine(logicalLine, width)...) + } + return colorizeNodeLines(lines) +} + +func wrapNodeLogicalLine(value string, width int) []string { + if value == "" { + return []string{""} + } + + tokens := tokenizeNodeText(value) + lines := make([]string, 0, 1) + for len(tokens) > 0 { + line, rest := splitNodeTokensAtBoundary(tokens, width) + lines = append(lines, nodeTokensString(line)) + tokens = rest + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func tokenizeNodeText(value string) []nodeWrapToken { + var tokens []nodeWrapToken + appendVisible := func(text string) { + for _, r := range text { + tokens = append(tokens, nodeWrapToken{ + raw: string(r), + width: runewidth.RuneWidth(r), + whitespace: unicode.IsSpace(r), + }) + } + } + + position := 0 + for _, location := range nodeANSIRegexp.FindAllStringIndex(value, -1) { + appendVisible(value[position:location[0]]) + tokens = append(tokens, nodeWrapToken{raw: value[location[0]:location[1]]}) + position = location[1] + } + appendVisible(value[position:]) + return tokens +} + +func nodeTokensWidth(tokens []nodeWrapToken) int { + width := 0 + for _, token := range tokens { + width += token.width + } + return width +} + +func nodeTokensString(tokens []nodeWrapToken) string { + var value strings.Builder + for _, token := range tokens { + value.WriteString(token.raw) + } + return value.String() +} + +func splitNodeTokensAtBoundary(tokens []nodeWrapToken, width int) (line, rest []nodeWrapToken) { + visibleWidth := 0 + lastWhitespaceStart := -1 + lastWhitespaceEnd := -1 + + i := 0 + for i < len(tokens) { + if tokens[i].whitespace { + start := i + widthBeforeWhitespace := visibleWidth + for i < len(tokens) && tokens[i].whitespace { + visibleWidth += tokens[i].width + i++ + } + if widthBeforeWhitespace > 0 && widthBeforeWhitespace <= width { + lastWhitespaceStart = start + lastWhitespaceEnd = i + } + if visibleWidth > width { + break + } + continue + } + + if tokens[i].width > 0 && visibleWidth+tokens[i].width > width { + break + } + visibleWidth += tokens[i].width + i++ + } + if i == len(tokens) && visibleWidth <= width { + return tokens, nil + } + + if lastWhitespaceStart >= 0 { + line = tokens[:lastWhitespaceStart] + rest = tokens[lastWhitespaceEnd:] + if nodeTokensWidth(line) > 0 { + return line, rest + } + } + + visibleWidth = 0 + cut := 0 + hasVisibleToken := false + for cut < len(tokens) { + token := tokens[cut] + if token.width > 0 && visibleWidth+token.width > width { + if hasVisibleToken { + break + } + cut++ + hasVisibleToken = true + break + } + visibleWidth += token.width + if token.width > 0 { + hasVisibleToken = true + } + cut++ + } + for cut < len(tokens) && tokens[cut].width == 0 && !tokens[cut].whitespace { + cut++ + } + return tokens[:cut], tokens[cut:] +} + +func colorizeNodeLines(lines []string) []string { + state := nodeSGRState{styles: make(map[int]string)} + colored := make([]string, len(lines)) + for i, line := range lines { + line = state.prefix() + line + state.update(line) + colored[i] = line + state.suffix() + } + return colored +} + +func (s *nodeSGRState) update(line string) { + for _, match := range nodeSGRRegexp.FindAllStringSubmatch(line, -1) { + codes := []int{0} + if match[1] != "" { + codes = codes[:0] + for _, value := range strings.Split(match[1], ";") { + code, err := strconv.Atoi(value) + if err == nil { + codes = append(codes, code) + } + } + } + for i := 0; i < len(codes); i++ { + code := codes[i] + if code == 38 || code == 48 { + length := nodeExtendedColorLength(codes[i:]) + s.updateCode(code, nodeSGRSequence(codes[i:i+length])) + i += length - 1 + continue + } + s.updateCode(code, nodeSGRSequence([]int{code})) + } + } +} + +func nodeExtendedColorLength(codes []int) int { + if len(codes) < 2 { + return 1 + } + want := 1 + switch codes[1] { + case 2: + want = 5 + case 5: + want = 3 + } + if want > len(codes) { + return len(codes) + } + return want +} + +func nodeSGRSequence(codes []int) string { + var sequence strings.Builder + sequence.WriteString("\x1b[") + for i, code := range codes { + if i > 0 { + sequence.WriteByte(';') + } + sequence.WriteString(strconv.Itoa(code)) + } + sequence.WriteByte('m') + return sequence.String() +} + +func (s *nodeSGRState) updateCode(code int, raw string) { + switch { + case code == 0: + s.foreground = "" + s.background = "" + clear(s.styles) + case code == 1 || code == 2 || code == 3 || code == 4 || code == 5 || code == 7 || code == 8 || code == 9: + s.styles[code] = raw + case code == 22: + delete(s.styles, 1) + delete(s.styles, 2) + case code == 23: + delete(s.styles, 3) + case code == 24: + delete(s.styles, 4) + case code == 25: + delete(s.styles, 5) + case code == 27: + delete(s.styles, 7) + case code == 28: + delete(s.styles, 8) + case code == 29: + delete(s.styles, 9) + case (code >= 30 && code <= 38) || (code >= 90 && code <= 97): + s.foreground = raw + case code == 39: + s.foreground = "" + case (code >= 40 && code <= 48) || (code >= 100 && code <= 107): + s.background = raw + case code == 49: + s.background = "" + } +} + +func (s nodeSGRState) prefix() string { + var prefix strings.Builder + for _, code := range nodeStyleCodes { + prefix.WriteString(s.styles[code]) + } + prefix.WriteString(s.background) + prefix.WriteString(s.foreground) + return prefix.String() +} + +func (s nodeSGRState) suffix() string { + var suffix strings.Builder + for _, code := range nodeStyleCodes { + if s.styles[code] != "" { + suffix.WriteString(nodeStyleCloseCodes[code]) + } + } + if s.background != "" { + suffix.WriteString("\x1b[49m") + } + if s.foreground != "" { + suffix.WriteString("\x1b[39m") + } + return suffix.String() +} diff --git a/internal/output/table_layout_test.go b/internal/output/table_layout_test.go new file mode 100644 index 000000000..b0d43d90a --- /dev/null +++ b/internal/output/table_layout_test.go @@ -0,0 +1,107 @@ +package output + +import ( + "strings" + "testing" +) + +func TestNodeColumnWidthsPreserveShortColumnsAndShrinkLongest(t *testing.T) { + headers := []string{"timestamp", "message"} + rows := [][]string{{ + "2026-07-15T07:17:38.002797318Z", + strings.Repeat("long message ", 20), + }} + + got := nodeColumnWidths(headers, rows, 78) + want := []int{30, 41} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("nodeColumnWidths() = %v, want %v", got, want) + } + if width := nodeTableWidth(got); width != 78 { + t.Fatalf("nodeTableWidth() = %d, want 78", width) + } +} + +func TestNodeColumnWidthsFitManyColumns(t *testing.T) { + headers := []string{"timestamp", "rows sent", "rows examined", "query time", "request uri", "query"} + rows := [][]string{{ + "2026-07-15T07:17:38.002797318Z", "10", "1000", "1.234", + "/wp-admin/edit.php?post_type=very-long-value", + strings.Repeat("SELECT post_id FROM wp_posts ", 20), + }} + + widths := nodeColumnWidths(headers, rows, 78) + if width := nodeTableWidth(widths); width > 78 { + t.Fatalf("nodeTableWidth(%v) = %d, want <= 78", widths, width) + } +} + +func TestNodeColumnWidthsUseStructuralMinimumWhenTerminalIsTooNarrow(t *testing.T) { + widths := nodeColumnWidths([]string{"alpha", "beta", "gamma"}, [][]string{{"a", "b", "c"}}, 5) + want := []int{1, 1, 1} + if len(widths) != len(want) || widths[0] != 1 || widths[1] != 1 || widths[2] != 1 { + t.Fatalf("nodeColumnWidths() = %v, want %v", widths, want) + } +} + +func TestNodeColumnWidthsGiveEmptyColumnsOneDisplayCellWhenConstrained(t *testing.T) { + widths := nodeColumnWidths([]string{"", "message"}, [][]string{{"", strings.Repeat("wide ", 20)}}, 12) + if len(widths) != 2 || widths[0] != 1 { + t.Fatalf("nodeColumnWidths() = %v, want empty constrained column width 1", widths) + } +} + +func TestNodeColumnWidthsKeepNaturalEmptyColumnZeroWithoutConstraint(t *testing.T) { + widths := nodeColumnWidths([]string{""}, [][]string{{""}}, 0) + if len(widths) != 1 || widths[0] != 0 { + t.Fatalf("nodeColumnWidths() = %v, want original natural width [0]", widths) + } +} + +func TestWrapNodeCellUsesWordsAndHardWrapsLongTokens(t *testing.T) { + if got, want := wrapNodeCell("alpha beta gamma", 10), []string{"alpha beta", "gamma"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("word wrap = %#v, want %#v", got, want) + } + if got, want := wrapNodeCell("abcdefghijk", 5), []string{"abcde", "fghij", "k"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("hard wrap = %#v, want %#v", got, want) + } +} + +func TestWrapNodeCellPreservesExplicitNewlinesAndUnicodeWidth(t *testing.T) { + if got, want := wrapNodeCell("alpha\n\nbeta", 20), []string{"alpha", "", "beta"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("explicit lines = %#v, want %#v", got, want) + } + if got, want := wrapNodeCell("界界界", 4), []string{"界界", "界"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("Unicode wrap = %#v, want %#v", got, want) + } +} + +func TestWrapNodeCellPreservesANSIStateAcrossGeneratedLines(t *testing.T) { + got := wrapNodeCell("\x1b[31malpha beta gamma\x1b[39m", 10) + if len(got) != 2 { + t.Fatalf("wrapped lines = %#v, want 2 lines", got) + } + if stripNodeANSI(got[0]) != "alpha beta" || stripNodeANSI(got[1]) != "gamma" { + t.Fatalf("visible wrapped lines = %#v", got) + } + for i, line := range got { + if !strings.Contains(line, "\x1b[31m") || !strings.Contains(line, "\x1b[39m") { + t.Fatalf("line %d does not contain balanced foreground state: %q", i, line) + } + } +} + +func TestWrapNodeCellPreservesTrueColorANSIStateAcrossGeneratedLines(t *testing.T) { + const open = "\x1b[38;2;255;31;0m" + const close = "\x1b[39m" + + got := wrapNodeCell(open+"alpha beta gamma"+close, 10) + if len(got) != 2 { + t.Fatalf("wrapped lines = %#v, want 2 lines", got) + } + for i, line := range got { + if strings.Count(line, open) != 1 || strings.Count(line, close) != 1 { + t.Fatalf("line %d does not contain one balanced true-color state: %q", i, line) + } + } +} diff --git a/internal/output/table_layout_tty_test.go b/internal/output/table_layout_tty_test.go new file mode 100644 index 000000000..37fdfa14d --- /dev/null +++ b/internal/output/table_layout_tty_test.go @@ -0,0 +1,63 @@ +//go:build !windows + +package output + +import ( + "bufio" + "strings" + "testing" + + "github.com/creack/pty" +) + +func TestTerminalTableWidthUsesTTYColumnsWithSafetyMargin(t *testing.T) { + primary, replica, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = primary.Close() }() + defer func() { _ = replica.Close() }() + + if err := pty.Setsize(replica, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + if got := terminalTableWidth(replica); got != 78 { + t.Fatalf("terminalTableWidth() = %d, want 78", got) + } + if !terminalTableIsTTY(replica) { + t.Fatal("terminalTableIsTTY(pty) = false, want true") + } +} + +// The other half of the TTY gate: written to a real terminal, the table keeps +// the grey border and bright-blue head. Without this, "strip ANSI when not a +// TTY" could be satisfied by stripping it everywhere. +func TestRenderNodeTableToTTYKeepsANSI(t *testing.T) { + primary, replica, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = primary.Close() }() + defer func() { _ = replica.Close() }() + + if err := pty.Setsize(replica, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + + // Read concurrently: a pty has a small kernel buffer and the writer would + // block once it fills. + lines := make(chan string, 1) + go func() { + reader := bufio.NewReader(primary) + line, _ := reader.ReadString('\n') + lines <- line + }() + + if err := renderNodeTable(replica, []string{"id"}, [][]string{{"1"}}); err != nil { + t.Fatal(err) + } + first := <-lines + if !strings.Contains(first, "\x1b[90m") { + t.Fatalf("table written to a TTY lost its border colour: %q", first) + } +} diff --git a/internal/output/text.go b/internal/output/text.go new file mode 100644 index 000000000..f39daf0e4 --- /dev/null +++ b/internal/output/text.go @@ -0,0 +1,24 @@ +package output + +import ( + "fmt" + "io" + "strings" +) + +func renderText(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("text renderer requires OrderedRows, got %T", data) + } + for _, r := range rows { + parts := make([]string, 0, len(r)) + for _, c := range r { + parts = append(parts, fmt.Sprint(c.Value)) + } + if _, err := fmt.Fprintln(w, strings.Join(parts, " ")); err != nil { + return err + } + } + return nil +} diff --git a/internal/output/text_test.go b/internal/output/text_test.go new file mode 100644 index 000000000..842f31a74 --- /dev/null +++ b/internal/output/text_test.go @@ -0,0 +1,31 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderTextOrderedRows(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "timestamp", Value: "2026-06-08T00:00:00Z"}, {Key: "message", Value: "hello"}}, + {{Key: "timestamp", Value: "2026-06-08T00:00:01Z"}, {Key: "message", Value: "world"}}, + } + if err := renderText(&buf, rows); err != nil { + t.Fatalf("renderText: %v", err) + } + want := "2026-06-08T00:00:00Z hello\n2026-06-08T00:00:01Z world\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderTextEmpty(t *testing.T) { + var buf bytes.Buffer + if err := renderText(&buf, OrderedRows{}); err != nil { + t.Fatalf("renderText: %v", err) + } + if buf.Len() != 0 { + t.Errorf("empty input must produce empty output; got %q", buf.String()) + } +} diff --git a/internal/output/typename.go b/internal/output/typename.go new file mode 100644 index 000000000..75ed96b09 --- /dev/null +++ b/internal/output/typename.go @@ -0,0 +1,35 @@ +package output + +import ( + json "encoding/json/v2" +) + +// StripTypename decodes the input into a generic structure, recursively +// removes every "__typename" key, and re-encodes. Used by the gql layer +// to clean responses before they reach command handlers. +func StripTypename(in []byte) ([]byte, error) { + var doc any + if err := json.Unmarshal(in, &doc); err != nil { + return nil, err + } + stripWalk(&doc) + return json.Marshal(doc, json.Deterministic(true)) +} + +func stripWalk(v *any) { + switch t := (*v).(type) { + case map[string]any: + delete(t, "__typename") + for k := range t { + child := t[k] + stripWalk(&child) + t[k] = child + } + case []any: + for i := range t { + child := t[i] + stripWalk(&child) + t[i] = child + } + } +} diff --git a/internal/output/typename_test.go b/internal/output/typename_test.go new file mode 100644 index 000000000..86cb78558 --- /dev/null +++ b/internal/output/typename_test.go @@ -0,0 +1,48 @@ +package output + +import ( + "strings" + "testing" +) + +func TestStripTypenameRemovesField(t *testing.T) { + in := `{"id":1,"name":"alpha","__typename":"App"}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Contains(string(got), "__typename") { + t.Errorf("__typename not removed: %s", got) + } + if !strings.Contains(string(got), `"name":"alpha"`) { + t.Errorf("other fields lost: %s", got) + } +} + +func TestStripTypenameRecursive(t *testing.T) { + in := `{"a":{"__typename":"X","b":[{"__typename":"Y","c":2}]}}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Count(string(got), "__typename") != 0 { + t.Errorf("nested __typename not removed: %s", got) + } + if !strings.Contains(string(got), `"c":2`) { + t.Errorf("leaf data lost: %s", got) + } +} + +func TestStripTypenamePreservesArrays(t *testing.T) { + in := `{"items":[{"id":1,"__typename":"A"},{"id":2,"__typename":"B"}]}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Contains(string(got), "__typename") { + t.Errorf("__typename in array not removed: %s", got) + } + if !strings.Contains(string(got), `"id":1`) || !strings.Contains(string(got), `"id":2`) { + t.Errorf("array entries lost: %s", got) + } +} diff --git a/internal/parity/backup_export_deploy_scenario_test.go b/internal/parity/backup_export_deploy_scenario_test.go new file mode 100644 index 000000000..dd39b9ea0 --- /dev/null +++ b/internal/parity/backup_export_deploy_scenario_test.go @@ -0,0 +1,285 @@ +//go:build parity + +package parity + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" +) + +// m7cMux dispatches the backup/export/deploy GraphQL operations plus the +// presign + S3 endpoints. Per-scenario recordings fall back to +// m7c-shared/ file-by-file. Sequenced fixtures use numeric suffixes +// (backup-status-1.json, backup-status-2.json, ...). +func m7cMux(t *testing.T, recordingDir string) (http.Handler, func(op string) int32) { + t.Helper() + shared := "../../testdata/parity/recordings/m7c-shared/" + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + read := func(name string) []byte { + if b, err := os.ReadFile(base + name); err == nil { + return b + } + if b, err := os.ReadFile(shared + name); err == nil { + return b + } + return nil + } + readSeq := func(prefix string, n int32) []byte { + // Clamp to the last existing fixture. + for i := n; i >= 1; i-- { + if b := read(fmt.Sprintf("%s-%d.json", prefix, i)); b != nil { + return b + } + } + return nil + } + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + hits := map[string]*int32{ + "TriggerDatabaseBackup": new(int32), + "BackupDBCopy": new(int32), + "StartCustomDeploy": new(int32), + "backupStatus": new(int32), + "exportStatus": new(int32), + } + + var srvURL atomic.Value + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + // `App` is Node's name for Go's ResolveAppByName/ByID + // (src/lib/api/app.ts:46,69). + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, read("resolve-app.json")) + case strings.Contains(s, `"operationName":"AppBackupJobStatus"`): + n := atomic.AddInt32(hits["backupStatus"], 1) + serve(w, readSeq("backup-status", n)) + case strings.Contains(s, `"operationName":"AppBackupAndJobStatus"`): + n := atomic.AddInt32(hits["exportStatus"], 1) + serve(w, readSeq("export-status", n)) + case strings.Contains(s, `"operationName":"TriggerDatabaseBackup"`): + atomic.AddInt32(hits["TriggerDatabaseBackup"], 1) + serve(w, []byte(`{"data":{"triggerDatabaseBackup":{"success":true}}}`)) + case strings.Contains(s, `"operationName":"BackupDBCopy"`): + atomic.AddInt32(hits["BackupDBCopy"], 1) + serve(w, []byte(`{"data":{"startDBBackupCopy":{"message":"ok","success":true}}}`)) + case strings.Contains(s, `"operationName":"GenerateDBBackupCopyUrl"`): + u, _ := srvURL.Load().(string) + serve(w, []byte(`{"data":{"generateDBBackupCopyUrl":{"url":"`+u+`/download","success":true}}}`)) + case strings.Contains(s, `"operationName":"ValidateCustomDeployAccess"`): + serve(w, []byte(`{"data":{"validateCustomDeployAccess":{"success":true,"appId":42,"envId":7,"envType":"develop","envUniqueLabel":"develop","primaryDomainName":"example.com","launched":false}}}`)) + case strings.Contains(s, `"operationName":"StartCustomDeploy"`): + atomic.AddInt32(hits["StartCustomDeploy"], 1) + serve(w, []byte(`{"data":{"startCustomDeploy":{"success":true,"message":"queued"}}}`)) + default: + serve(w, nil) + } + }) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + u, _ := srvURL.Load().(string) + fmt.Fprintf(w, `{"url":"%s/s3target","options":{"method":"PUT","headers":{}}}`, u) + }) + mux.HandleFunc("/s3target", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/download", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("sql-archive-bytes")) + }) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if srvURL.Load() == nil { + srvURL.Store("http://" + r.Host) + } + mux.ServeHTTP(w, r) + }) + get := func(op string) int32 { + if p, ok := hits[op]; ok { + return atomic.LoadInt32(p) + } + return 0 + } + return handler, get +} + +// writeDeployFixtures creates the archive fixtures the deploy scenarios +// reference (generated, not committed binaries). +func writeDeployFixtures(t *testing.T) { + t.Helper() + dir := "../../testdata/parity/recordings/app-deploy-validate" + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + write := func(name string, dirs []string, files []string) { + p := filepath.Join(dir, name) + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + tw := tar.NewWriter(zw) + for _, d := range dirs { + if err := tw.WriteHeader(&tar.Header{Name: d, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + } + for _, fl := range files { + if err := tw.WriteHeader(&tar.Header{Name: fl, Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte("x")) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + } + write("clean.tar.gz", []string{"app/", "app/themes/"}, []string{"app/themes/style.css"}) + write("no-themes.tar.gz", []string{"app/"}, []string{"app/x.php"}) +} + +// TestM7cScenarios discovers the backup-db-*, export-sql-*, and +// app-deploy-* YAMLs and runs the Go binary against the stubbed API. +func TestM7cScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + var entries []string + for _, glob := range []string{"backup-db-*.yaml", "export-sql-*.yaml", "app-deploy-*.yaml"} { + matches, err := filepath.Glob(yamlDir + "/" + glob) + if err != nil { + t.Fatalf("glob: %v", err) + } + entries = append(entries, matches...) + } + sort.Strings(entries) + if len(entries) == 0 { + t.Fatal("no M7c scenarios found — testdata moved?") + } + + writeDeployFixtures(t) + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range entries { + name := strings.TrimSuffix(filepath.Base(path), ".yaml") + t.Run(name, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion", scenario.ExpectedDrift.Reason) + return + } + + handler, opHits := m7cMux(t, scenario.Recording) + srv := httptest.NewServer(handler) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("exit=%d, want %d\n stderr: %s\n stdout: %s", + res.ExitCode, scenario.Expect.ExitCode, res.Stderr, res.Stdout) + } + + combined := res.Stdout + res.Stderr + switch name { + case "backup-db-help": + if !strings.Contains(combined, "backup") { + t.Errorf("help output:\n%s", combined) + } + case "backup-db-completed": + if !strings.Contains(combined, "Generating a new database backup...") || + !strings.Contains(combined, "New database backup created") { + t.Errorf("missing backup logs:\n%s", combined) + } + if opHits("TriggerDatabaseBackup") != 1 { + t.Errorf("Trigger hits = %d, want 1", opHits("TriggerDatabaseBackup")) + } + case "backup-db-already-in-progress": + if !strings.Contains(combined, "Database backup already in progress...") { + t.Errorf("missing in-progress log:\n%s", combined) + } + if opHits("TriggerDatabaseBackup") != 0 { + t.Errorf("Trigger hits = %d, want 0", opHits("TriggerDatabaseBackup")) + } + case "export-sql-help": + for _, flag := range []string{"--output", "--table", "--site-id", "--wpcli-command", "--config-file", "--generate-backup", "--skip-download"} { + if !strings.Contains(combined, flag) { + t.Errorf("help missing %s:\n%s", flag, combined) + } + } + case "export-sql-completed": + if opHits("BackupDBCopy") != 1 { + t.Errorf("BackupDBCopy hits = %d, want 1", opHits("BackupDBCopy")) + } + if !strings.Contains(combined, "Exporting database backup with timestamp 2026-06-11 10:00:00") { + t.Errorf("missing prepare info:\n%s", combined) + } + case "export-sql-config-conflict": + if !strings.Contains(combined, "The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options.") { + t.Errorf("missing exclusivity message:\n%s", combined) + } + case "app-deploy-missing-token": + if !strings.Contains(combined, "Valid custom deploy key is required.") { + t.Errorf("missing token message:\n%s", combined) + } + case "app-deploy-completed": + if opHits("StartCustomDeploy") != 1 { + t.Errorf("StartCustomDeploy hits = %d, want 1", opHits("StartCustomDeploy")) + } + if !strings.Contains(combined, "has been sent for deployment to example.com.") || + !strings.Contains(combined, "https://dashboard.wpvip.com/apps/42/develop/code/deployments") { + t.Errorf("missing success block:\n%s", combined) + } + case "app-deploy-validate-clean": + if !strings.Contains(combined, "✓ Compressed file has been successfully validated with no errors.") { + t.Errorf("missing success line:\n%s", combined) + } + case "app-deploy-validate-missing-themes": + if !strings.Contains(combined, "Missing `themes` directory from root folder.") { + t.Errorf("missing themes error:\n%s", combined) + } + } + }) + } +} diff --git a/internal/parity/cache_scenario_test.go b/internal/parity/cache_scenario_test.go new file mode 100644 index 000000000..8619f013d --- /dev/null +++ b/internal/parity/cache_scenario_test.go @@ -0,0 +1,173 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// cachePurgeMux dispatches GraphQL requests for the M6 cache purge +// scenarios. +// +// It must answer BOTH CLIs, because these recordings back real Node-vs-Go +// differentials (TestSurfaceDifferentialParity), not just vip-next. +// +// Go operation → file mapping: +// +// ResolveAppByName / ResolveAppByID -> resolve-app.json +// PurgePageCache -> purge.json +// +// Node operation → file mapping (names verified on the wire against trunk +// 4.1.0, not inferred): +// +// App (src/lib/api/app.ts:46,69) -> resolve-app.json +// PurgePageCacheMutation (src/lib/api/cache-purge.ts:12) -> purge.json +// +// Node's mutation carries a redundant `Mutation` suffix that Go's does not, so +// routing on Go's name alone leaves the real Node CLI talking to the default +// branch and purging nothing. +// +// Missing files fall back to {"data":null}. The mutation hit counter is +// returned so the empty scenario can assert the mutation was NOT called. +func cachePurgeMux(t *testing.T, recordingDir string) (http.Handler, func() int32) { + t.Helper() + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + maybeRead := func(name string) []byte { + b, err := os.ReadFile(base + name) + if err != nil { + return nil + } + return b + } + + resolveAppBody := maybeRead("resolve-app.json") + purgeBody := maybeRead("purge.json") + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var purgeHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, resolveAppBody) + case strings.Contains(s, `"operationName":"PurgePageCache"`), + strings.Contains(s, `"operationName":"PurgePageCacheMutation"`): + atomic.AddInt32(&purgeHits, 1) + serve(w, purgeBody) + default: + serve(w, nil) + } + }) + hits := func() int32 { return atomic.LoadInt32(&purgeHits) } + return mux, hits +} + +var cachePurgePrefixes = []string{ + "cache-purge-url-", +} + +func isCachePurgeScenario(name string) bool { + for _, p := range cachePurgePrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// cachePurgeNoMutationScenarios names the scenarios where the PurgePageCache +// mutation MUST NOT fire (e.g. empty URL list, validated client-side before +// the GraphQL call). +var cachePurgeNoMutationScenarios = map[string]bool{ + "cache-purge-url-empty": true, +} + +// TestM6CachePurgeScenarios discovers every YAML matching cache-purge-url-* +// and runs it against cachePurgeMux. +func TestM6CachePurgeScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + var scenarios []string + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if isCachePurgeScenario(base) { + scenarios = append(scenarios, path) + } + } + if len(scenarios) == 0 { + t.Fatal("no M6 cache purge scenarios found — testdata may have moved") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range scenarios { + scenarioName := strings.TrimSuffix(filepath.Base(path), ".yaml") + + t.Run(scenarioName, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion for %s", scenario.ExpectedDrift.Reason, scenarioName) + return + } + + mux, hits := cachePurgeMux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run(%s): %v", scenarioName, err) + } + + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("%s: exit code = %d, want %d\n stderr: %s\n stdout: %s", + scenarioName, res.ExitCode, scenario.Expect.ExitCode, + res.Stderr, res.Stdout) + } + + // Wire-level assertion: no-mutation scenarios MUST NOT fire purge. + if cachePurgeNoMutationScenarios[scenarioName] { + if h := hits(); h != 0 { + t.Errorf("%s: scenario must not call PurgePageCache; got hits=%d", scenarioName, h) + } + } + }) + } +} diff --git a/internal/parity/command_surface_scenario_test.go b/internal/parity/command_surface_scenario_test.go new file mode 100644 index 000000000..d2ef31d49 --- /dev/null +++ b/internal/parity/command_surface_scenario_test.go @@ -0,0 +1,530 @@ +//go:build parity + +package parity + +// TestCommandSurfaceScenarios tests the command-surface completion milestone: +// login, logout, search-replace, dev-env stubs, config software get/update. +// +// Each subtest is hermetic (httptest servers, temp files, in-process fakes). + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// commandSurfaceBaseEnv is the common env for all command-surface scenarios. +func commandSurfaceBaseEnv() map[string]string { + return map[string]string{ + "DO_NOT_TRACK": "1", + "NODE_ENV": "test", + "NO_COLOR": "1", + } +} + +// csResolveAppJSON is the resolve-app response for parityapp/develop (typeId 2, +// WordPress). Used by scenarios that need app resolution. +const csResolveAppJSON = `{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}]}]}}}` + +// csSoftwareSettingsJSON is the SoftwareSettings response body for a WP +// environment (typeId 2). WordPress has current="6.3" and option "6.4". +// PHP, muplugins, and nodejs are null (single-component focus keeps the +// fixture simple). +const csSoftwareSettingsJSON = `{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"type":"develop","name":"develop","softwareSettings":{"wordpress":{"name":"WordPress","slug":"wordpress","pinned":false,"current":{"version":"6.3","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},"options":[{"version":"6.3","default":false,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false},{"version":"6.4","default":true,"deprecated":false,"unstable":false,"compatible":true,"latestRelease":"6.4","private":false}]},"php":null,"muplugins":null,"nodejs":null}}]}}}` + +// csUpdateMutationOKJSON is a successful UpdateSoftwareSettings mutation body. +const csUpdateMutationOKJSON = `{"data":{"updateSoftwareSettings":{"wordpress":null,"php":null,"muplugins":null,"nodejs":null}}}` + +// csSoftwareJobSuccessJSON is a SoftwareUpdateJob poll response: success. +const csSoftwareJobSuccessJSON = `{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[{"__typename":"Job","type":"software_update","completedAt":"2024-01-01T00:01:00Z","createdAt":"2024-01-01T00:00:00Z","inProgressLock":false,"progress":{"status":"success","steps":[]}}]}]}}}` + +// csMux builds an httptest server that routes GraphQL requests by operationName. +// resolveApp, softwareSettings, updateMutation, and softwareJob bodies can each +// be nil (the server returns {"data":null} for unrecognized ops). +// +// Returns the handler and a hit-counter for UpdateSoftwareSettings mutations. +func csMux( + t *testing.T, + resolveApp []byte, + softwareSettings []byte, + updateMutation []byte, + softwareJob []byte, +) (http.Handler, func() int32) { + t.Helper() + + null := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = null + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var updateHits int32 + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`): + serve(w, resolveApp) + case strings.Contains(s, `"operationName":"SoftwareSettings"`): + serve(w, softwareSettings) + case strings.Contains(s, `"operationName":"UpdateSoftwareSettings"`): + atomic.AddInt32(&updateHits, 1) + serve(w, updateMutation) + case strings.Contains(s, `"operationName":"SoftwareUpdateJob"`): + serve(w, softwareJob) + default: + serve(w, nil) + } + }), func() int32 { return atomic.LoadInt32(&updateHits) } +} + +// fakeSRBin writes a POSIX shell script that passes stdin directly to stdout +// (identity replacement — we test plumbing, not the replacement logic). +// The binary honours the go-search-replace calling convention: it reads stdin +// and writes replaced content to stdout; replacement-pair args are ignored in +// this stub. The file is marked executable. +// +// Skip on Windows (POSIX shebang not supported there). +func fakeSRBin(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake search-replace binary is POSIX-only") + } + dir := t.TempDir() + p := filepath.Join(dir, "go-search-replace") + // Pass stdin to stdout unchanged so the output equals the input. + if err := os.WriteFile(p, []byte("#!/bin/sh\ncat\n"), 0o755); err != nil { // #nosec G306 + t.Fatalf("write fake sr bin: %v", err) + } + return p +} + +// ─── subtests ──────────────────────────────────────────────────────────────── + +func TestCommandSurfaceScenarios(t *testing.T) { + goBin := buildVipNextWithVersion(t, "test", "test") + + // ── 1. dev-env routing ──────────────────────────────────────────────────── + // dev-env subcommands are implemented (Plan 5) and auth-bypassed (no + // VIP_TOKEN_OVERRIDE or GraphQL server needed). These scenarios assert the + // command tree routes to the right leaf and the leaf runs — with an isolated + // (empty) data dir so they never touch the host's real environments. + t.Run("dev-env-start-routes-to-leaf", func(t *testing.T) { + env := commandSurfaceBaseEnv() + env["XDG_DATA_HOME"] = t.TempDir() // hermetic: no real environments + + // `start --slug=foo` must reach the implemented start leaf and run it; + // with no env on disk it fails reading the env's instance data. That + // proves routing + auth-bypass (no auth wall, no "unknown command"). + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"dev-env", "start", "--slug=foo"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode == 0 { + t.Errorf("exit=0, want non-zero (missing env should fail)\n stderr: %s\n stdout: %s", + res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "instance_data.json") { + t.Errorf("expected env-not-found error from the start leaf; got:\n%s", combined) + } + }) + + t.Run("dev-env-sync-sql-routes-to-child", func(t *testing.T) { + // `sync sql` is a child of the special `sync` node; routing must descend + // to the `sql` child rather than invoking `sync`'s RunE. The sql leaf is + // wired through the appctx app/env middleware, so with no app provided it + // fails "--app is required" — an error only the leaf's own middleware + // chain emits (the parent `sync` has no RunE), proving routing reached the + // child. + env := commandSurfaceBaseEnv() + env["XDG_DATA_HOME"] = t.TempDir() + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"dev-env", "sync", "sql", "--slug=foo"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode == 0 { + t.Errorf("exit=0, want non-zero\n stderr: %s\n stdout: %s", + res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "--app is required") { + t.Errorf("expected sql leaf to require --app (proves nested routing); got:\n%s", combined) + } + }) + + // ── 2. search-replace ───────────────────────────────────────────────────── + // search-replace is auth-bypassed (no @app.env, no token needed). We set + // VIP_SEARCH_REPLACE_BIN to a fake binary that passes stdin to stdout + // unchanged so we can assert the plumbing without a real binary. + // + // search-replace is NOT on the cobra auth-bypass list (it's a standalone + // command without @app), so the binary requires VIP_TOKEN_OVERRIDE to be + // set (else it exits 1 with "not logged in"). However inspection of bypass.go + // shows only login/logout/dev-env/help/version are bypassed — search-replace + // needs a token. We supply one (even though no server is needed for the + // redirect behaviour of vip search-replace). + t.Run("search-replace-stdout", func(t *testing.T) { + srBin := fakeSRBin(t) + + // Write a temp SQL file (plain mysqldump; cat stub returns it unchanged). + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "dump.sql") + content := "INSERT INTO wp_options (option_name) VALUES ('http://oldsite.example');\n" + if err := os.WriteFile(inputFile, []byte(content), 0o600); err != nil { + t.Fatalf("write input: %v", err) + } + + // Stand up a minimal server so the token-auth path gets a valid resolve. + // (search-replace is NOT app-context-aware, so the server will never + // actually be queried — but the token check in main.go requires a valid + // JWT to not reject before the command even runs.) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":null}`)) + })) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + env["VIP_SEARCH_REPLACE_BIN"] = srBin + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"search-replace", inputFile, "--search-replace=from,to"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + // Fake binary (cat) returns the content unchanged; command streams it to + // stdout. + if !strings.Contains(res.Stdout, "wp_options") { + t.Errorf("stdout missing expected SQL content; got %q", res.Stdout) + } + }) + + t.Run("search-replace-inplace", func(t *testing.T) { + srBin := fakeSRBin(t) + + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "dump.sql") + content := "SELECT 1;\n" + if err := os.WriteFile(inputFile, []byte(content), 0o600); err != nil { + t.Fatalf("write input: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":null}`)) + })) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + env["VIP_SEARCH_REPLACE_BIN"] = srBin + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"search-replace", inputFile, "--search-replace=SELECT,REPLACED", "--in-place"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + // This scenario used to assert exit 0 plus a rewritten file — i.e. it + // enshrined parity blocker B2, an irreversible in-place rewrite with no + // confirmation. Node prompts here and defaults to No + // (search-and-replace.ts:151; the standalone bin passes no batchMode, + // vip-search-replace.js:74). The harness runs the binary as a + // subprocess with no TTY, so the confirm cannot be answered and the + // command must refuse without touching the file. + if res.ExitCode == 0 { + t.Errorf("exit=0: --in-place must not proceed when the confirmation cannot be shown\n stderr: %s", res.Stderr) + } + if !strings.Contains(res.Stderr, "This operation is not reversible") { + t.Errorf("stderr missing Node's in-place confirmation text; got %q", res.Stderr) + } + got, err := os.ReadFile(inputFile) + if err != nil { + t.Fatalf("read inplace input: %v", err) + } + if string(got) != content { + t.Errorf("input file was modified without confirmation:\n got %q\nwant %q", got, content) + } + }) + + // ── 3. logout ───────────────────────────────────────────────────────────── + // logout is auth-bypassed (no login required). Stand up a server to capture + // POST /logout; set VIP_TOKEN_OVERRIDE so store.Load returns a token and + // PostLogout actually fires. + t.Run("logout", func(t *testing.T) { + var ( + logoutHits int32 + lastMethod string + lastPath string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/logout" { + atomic.AddInt32(&logoutHits, 1) + lastMethod = r.Method + lastPath = r.URL.Path + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"logout"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "You are now logged out.") { + t.Errorf("missing logout message; got:\n%s", combined) + } + if h := atomic.LoadInt32(&logoutHits); h != 1 { + t.Errorf("POST /logout hits = %d, want 1", h) + } + if lastMethod != http.MethodPost || lastPath != "/logout" { + t.Errorf("server saw %s %s, want POST /logout", lastMethod, lastPath) + } + }) + + // ── 4. config-software-get ──────────────────────────────────────────────── + // Stand up a mux with SoftwareSettings + ResolveApp responses. + // Assert exit 0 and that table output mentions WordPress/PHP (or just + // WordPress, since PHP is null in our fixture). + t.Run("config-software-get", func(t *testing.T) { + handler, _ := csMux(t, + []byte(csResolveAppJSON), + []byte(csSoftwareSettingsJSON), + nil, + nil, + ) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "config", "software", "get"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "WordPress") { + t.Errorf("output missing 'WordPress'; got:\n%s", combined) + } + if !strings.Contains(combined, "6.3") { + t.Errorf("output missing current version '6.3'; got:\n%s", combined) + } + }) + + t.Run("config-software-get-json", func(t *testing.T) { + handler, _ := csMux(t, + []byte(csResolveAppJSON), + []byte(csSoftwareSettingsJSON), + nil, + nil, + ) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "config", "software", "get", "--format=json"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + // stdout should be valid JSON + var decoded any + if err := json.Unmarshal([]byte(strings.TrimSpace(res.Stdout)), &decoded); err != nil { + t.Errorf("--format=json output is not valid JSON: %v\n stdout: %s", err, res.Stdout) + } + }) + + // ── 5. config-software-update ───────────────────────────────────────────── + // Mux returns: SoftwareSettings (for validation), UpdateSoftwareSettings OK, + // SoftwareUpdateJob returning status="success" immediately (no sleep needed). + // --yes skips the confirm prompt. + t.Run("config-software-update", func(t *testing.T) { + handler, updateHits := csMux(t, + []byte(csResolveAppJSON), + []byte(csSoftwareSettingsJSON), + []byte(csUpdateMutationOKJSON), + []byte(csSoftwareJobSuccessJSON), + ) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "config", "software", "update", "wordpress", "6.4", "--yes"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + // Success message from runConfigSoftwareUpdate. + if !strings.Contains(combined, "Successfully updated") { + t.Errorf("missing success message; got:\n%s", combined) + } + if h := updateHits(); h != 1 { + t.Errorf("UpdateSoftwareSettings hits = %d, want 1", h) + } + }) + + // ── 5b. config-software-update WITHOUT --yes ───────────────────────────── + // The real-process assertion behind the exit-code fix. A subprocess has no + // TTY on stdin, so this is exactly the shape of a CI run that forgot --yes: + // the confirm cannot be answered, the update never happens, and the command + // must FAIL. + // + // Node throws UserError( 'Update canceled' ) from promptForUpdate + // (src/lib/config/software.ts:335); command.js's unhandledRejection handler + // routes a UserError to exit.withError (src/lib/cli/command.js:27-28 → + // src/lib/cli/exit.ts `process.exit( 1 )`). vip-next printed "Update + // canceled" and exited 0, so CI reported a green software update on a no-op. + t.Run("config-software-update-declined-exits-1", func(t *testing.T) { + handler, updateHits := csMux(t, + []byte(csResolveAppJSON), + []byte(csSoftwareSettingsJSON), + []byte(csUpdateMutationOKJSON), + []byte(csSoftwareJobSuccessJSON), + ) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := commandSurfaceBaseEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + // deliberately no --yes + Argv: []string{"@parityapp.develop", "config", "software", "update", "wordpress", "6.4"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 1 { + t.Errorf("exit=%d, want 1\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "Update canceled") { + t.Errorf("missing 'Update canceled'; got:\n%s", combined) + } + if strings.Contains(combined, "Successfully updated") { + t.Errorf("a canceled update must not claim success; got:\n%s", combined) + } + // The whole point: the mutation never left the machine. + if h := updateHits(); h != 0 { + t.Errorf("UpdateSoftwareSettings hits = %d, want 0", h) + } + }) + + // ── 6. login ───────────────────────────────────────────────────────────── + // login is interactive (browser-open + token prompt) and cannot be driven + // end-to-end in a hermetic test. The full flow calls survey.AskOne on the + // real TTY; in a headless subprocess that gets EOF immediately, which is + // not ErrLoginCancelled, so the command exits 1. + // + // What we CAN assert hermetically: + // • `vip help login` exits 0 and shows the "Authenticate" description. + // This confirms the command is registered and cobra routes help correctly. + // + // NOTE: browser-open + token-entry is a manual-test-only scenario, per the + // spec's "not automated: browser-open" carve-out. login's surveyConfirm calls + // survey.AskOne which reads a real TTY and cannot be injected at the binary + // boundary. Testing the full login flow requires mocking at the login.go + // level (done in internal/auth/login_test.go), not at the binary level. + t.Run("login-help", func(t *testing.T) { + env := commandSurfaceBaseEnv() + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"help", "login"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "uthenticat") { // "Authenticate" or "authenticate" + t.Errorf("help output missing 'Authenticate'; got:\n%s", combined) + } + }) +} diff --git a/internal/parity/defensive_mode_scenario_test.go b/internal/parity/defensive_mode_scenario_test.go new file mode 100644 index 000000000..21fab4a8d --- /dev/null +++ b/internal/parity/defensive_mode_scenario_test.go @@ -0,0 +1,133 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" +) + +// TestDefensiveModeEnableWithRechallenge is the M3 acceptance scenario. +// +// Single mux serves both /graphql (GraphQL) and /parker/* (Parker REST) so +// rechallenge.Client can resolve relative Parker paths against the same API +// host. First mutation hit returns elevated-permission-required; the runner +// completes step-up against the Parker mock; second mutation hit succeeds +// with the elevated header attached. +// +// This test runs the actual vip-next Go binary end-to-end (not just the +// in-process middleware), which is what makes it the acceptance gate for M3. +func TestDefensiveModeEnableWithRechallenge(t *testing.T) { + read := func(name string) []byte { + b, err := os.ReadFile("../../testdata/parity/recordings/defensive-mode-enable-rechallenge/" + name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return b + } + mutationElevated := read("mutation-elevated.json") + mutationSuccess := read("mutation-success.json") + createSession := read("parker-create-session.json") + statusVerified := read("parker-status-verified.json") + exchange := read("parker-exchange.json") + + mutationHits := int32(0) + unauthenticatedParkerHits := int32(0) + var headerOnRetry string + var expectedAuthorization string + requirePrimaryAuth := func(w http.ResponseWriter, r *http.Request) bool { + if r.Header.Get("Authorization") == expectedAuthorization { + return true + } + atomic.AddInt32(&unauthenticatedParkerHits, 1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"missing primary token"}`)) + return false + } + + // Fixture for the M4 ResolveAppByName lookup that WithAppContext fires + // before the mutation. App id=42 named "parityapp" with a develop env + // of id=7 — matches the rest of the scenario (env=develop, env id 7 + // referenced in mutation-elevated/-success bodies). + resolveAppByNameBody := []byte(`{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}]}]}}}`) + + mux := http.NewServeMux() + mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + w.Header().Set("Content-Type", "application/json") + if strings.Contains(s, `"operationName":"ResolveAppByName"`) || + strings.Contains(s, `"operationName":"ResolveAppByID"`) { + w.Write(resolveAppByNameBody) + return + } + // Mutation path: first hit returns elevated-required, second hit + // (after step-up + retry) returns success. + n := atomic.AddInt32(&mutationHits, 1) + if n == 1 { + w.Write(mutationElevated) + return + } + headerOnRetry = r.Header.Get("x-elevated-token") + w.Write(mutationSuccess) + }) + mux.HandleFunc("/parker/sessions", func(w http.ResponseWriter, r *http.Request) { + if !requirePrimaryAuth(w, r) { + return + } + w.Write(createSession) + }) + mux.HandleFunc("/parker/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + if !requirePrimaryAuth(w, r) { + return + } + w.Write(statusVerified) + }) + mux.HandleFunc("/parker/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + if !requirePrimaryAuth(w, r) { + return + } + w.Write(exchange) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + scenario, err := LoadScenario("../../testdata/parity/defensive-mode-enable-with-rechallenge.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + token := makeTestToken(t) + expectedAuthorization = "Bearer " + token + scenario.Env["VIP_TOKEN_OVERRIDE"] = token + + goBin := buildVipNextWithVersion(t, "test", "test") + res, err := Run(RunSpec{Binary: goBin, Argv: scenario.Argv, Env: FixtureEnv(scenario.Env)}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0; stderr=%s; stdout=%s", res.ExitCode, res.Stderr, res.Stdout) + } + if mutationHits != 2 { + t.Errorf("mutation hits = %d, want 2 (elevated bounce + replay)", mutationHits) + } + if headerOnRetry != "elev-token-xyz" { + t.Errorf("retry header = %q, want elev-token-xyz", headerOnRetry) + } + if unauthenticatedParkerHits != 0 { + t.Errorf("unauthenticated Parker hits = %d, want 0", unauthenticatedParkerHits) + } + if !strings.Contains(res.Stdout, "Defensive mode enabled for parityapp.develop") { + t.Errorf("stdout missing success line; got=%q", res.Stdout) + } +} diff --git a/internal/parity/diff.go b/internal/parity/diff.go new file mode 100644 index 000000000..c353ba804 --- /dev/null +++ b/internal/parity/diff.go @@ -0,0 +1,128 @@ +//go:build parity + +package parity + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "regexp" +) + +type DiffResult struct { + Equal bool + ExitCodeDelta string + StdoutDelta string + StderrDelta string +} + +// ambientStderrRules strip environment-dependent noise from stderr before any +// comparison. This is not the same thing as a scenario's own normalize rules: +// those describe output a scenario chose to ignore, whereas these describe +// output that depends only on where the harness happens to be running. +// +// Keep this list tiny and each pattern anchored to a whole line. Every entry +// here is output the harness has been made blind to, so a pattern that is one +// character too broad silently stops catching real divergences. +var ambientStderrRules = []NormalizeRule{ + // On a headless Linux runner there is no D-Bus secret service, so vip-next + // reports that it fell back to a 0600 credentials file. The Node CLI uses + // configstore and has no equivalent concept, so it says nothing. Left in, + // this one line failed 32 differential scenarios on Linux that all passed + // on macOS, where a keychain is always available. + // + // The difference is real and user-visible on headless Linux; it is recorded + // in docs/CUTOVER-BREAKING-CHANGES.md rather than here, because a divergence + // that appears in every single scenario is a property of the environment, + // not of any one command. + {Pattern: `(?m)^warning: OS keyring unavailable; storing credentials in .*\n?`, Replacement: ""}, +} + +// normalizeStderr applies the ambient rules before the scenario's own, so that +// no scenario has to restate environment noise it never asked about. +func normalizeStderr(s string, rules []NormalizeRule) (string, error) { + s, err := ApplyNormalizers(s, ambientStderrRules) + if err != nil { + return "", err + } + return ApplyNormalizers(s, rules) +} + +func ApplyNormalizers(s string, rules []NormalizeRule) (string, error) { + for _, r := range rules { + re, err := regexp.Compile(r.Pattern) + if err != nil { + return "", fmt.Errorf("compile normalizer %q: %w", r.Pattern, err) + } + s = re.ReplaceAllString(s, r.Replacement) + } + return s, nil +} + +func Diff(s *Scenario, a, b *RunResult) (*DiffResult, error) { + aOut, err := ApplyNormalizers(a.Stdout, s.Normalize.Stdout) + if err != nil { + return nil, err + } + bOut, err := ApplyNormalizers(b.Stdout, s.Normalize.Stdout) + if err != nil { + return nil, err + } + aErr, err := normalizeStderr(a.Stderr, s.Normalize.Stderr) + if err != nil { + return nil, err + } + bErr, err := normalizeStderr(b.Stderr, s.Normalize.Stderr) + if err != nil { + return nil, err + } + + res := &DiffResult{Equal: true} + if a.ExitCode != b.ExitCode { + res.Equal = false + res.ExitCodeDelta = fmt.Sprintf("exit code: a=%d b=%d", a.ExitCode, b.ExitCode) + } + if aOut != bOut { + res.Equal = false + res.StdoutDelta = fmt.Sprintf("stdout diverges:\n--- a\n%s\n--- b\n%s", aOut, bOut) + } + if aErr != bErr { + res.Equal = false + res.StderrDelta = fmt.Sprintf("stderr diverges:\n--- a\n%s\n--- b\n%s", aErr, bErr) + } + if !res.Equal && s.ExpectedDrift != nil { + got := driftSignature(a.ExitCode, aOut, aErr, b.ExitCode, bOut, bErr) + if got != s.ExpectedDrift.Signature { + // Print what actually diverged, not just the hashes. A bare pair of + // signatures tells you a blessed drift moved but not how, which turns + // every mismatch into a bisect. This is the normalized output the + // signature was taken over, so what you read here is exactly what was + // hashed. + return nil, fmt.Errorf( + "expected_drift signature mismatch: recorded=%s actual=%s\n"+ + "normalized a (Node): exit=%d\n--- stdout\n%s\n--- stderr\n%q\n"+ + "normalized b (Go): exit=%d\n--- stdout\n%s\n--- stderr\n%q", + s.ExpectedDrift.Signature, got, + a.ExitCode, aOut, aErr, + b.ExitCode, bOut, bErr, + ) + } + } + return res, nil +} + +func driftSignature(nodeExit int, nodeStdout, nodeStderr string, goExit int, goStdout, goStderr string) string { + h := sha256.New() + write := func(label string, value string) { + _, _ = fmt.Fprintf(h, "%s:%d:", label, len(value)) + _, _ = io.WriteString(h, value) + } + write("node-exit", fmt.Sprintf("%d", nodeExit)) + write("node-stdout", nodeStdout) + write("node-stderr", nodeStderr) + write("go-exit", fmt.Sprintf("%d", goExit)) + write("go-stdout", goStdout) + write("go-stderr", goStderr) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/parity/diff_test.go b/internal/parity/diff_test.go new file mode 100644 index 000000000..65cd68dff --- /dev/null +++ b/internal/parity/diff_test.go @@ -0,0 +1,155 @@ +//go:build parity + +package parity + +import ( + "strings" + "testing" +) + +func TestApplyNormalizersStdout(t *testing.T) { + rules := []NormalizeRule{ + {Pattern: `vip-next \S+ \(commit \S+\)`, Replacement: `vip-next <VERSION> (commit <COMMIT>)`}, + } + in := "vip-next 1.2.3 (commit abcdef1)\n" + got, err := ApplyNormalizers(in, rules) + if err != nil { + t.Fatalf("ApplyNormalizers: %v", err) + } + want := "vip-next <VERSION> (commit <COMMIT>)\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDiffResultEqualWhenNormalized(t *testing.T) { + scenario := &Scenario{ + Argv: []string{"--version"}, + } + scenario.Normalize.Stdout = []NormalizeRule{ + {Pattern: `vip-next \S+ \(commit \S+\)`, Replacement: `vip-next <X> (commit <Y>)`}, + } + a := &RunResult{ExitCode: 0, Stdout: "vip-next 1.0.0 (commit abcd1234)\n"} + b := &RunResult{ExitCode: 0, Stdout: "vip-next 1.0.0 (commit deadbeef)\n"} + + d, err := Diff(scenario, a, b) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if !d.Equal { + t.Errorf("expected Equal after normalization; got %+v", d) + } +} + +func TestDiffResultUnequalOnExitCode(t *testing.T) { + scenario := &Scenario{Argv: []string{"--version"}} + a := &RunResult{ExitCode: 0} + b := &RunResult{ExitCode: 1} + + d, err := Diff(scenario, a, b) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if d.Equal { + t.Errorf("expected !Equal on differing exit codes; got Equal") + } + if d.ExitCodeDelta == "" { + t.Errorf("expected ExitCodeDelta to describe the divergence") + } +} + +func TestDiffRejectsAnAcceptedDriftWhoseOutputFingerprintChanged(t *testing.T) { + scenario := &Scenario{Argv: []string{"example"}} + scenario.ExpectedDrift = &ExpectedDrift{ + Reason: "intentional example", + Signature: strings.Repeat("0", 64), + } + node := &RunResult{ExitCode: 0, Stdout: "node output\n"} + goResult := &RunResult{ExitCode: 0, Stdout: "go output\n"} + + _, err := Diff(scenario, node, goResult) + if err == nil || !strings.Contains(err.Error(), "expected_drift signature mismatch") { + t.Fatalf("Diff error = %v, want expected_drift signature mismatch", err) + } +} + +// The keychain fallback notice is environment noise, not a behavioural +// divergence: on a headless Linux runner there is no D-Bus secret service, so +// vip-next reports that it fell back to a 0600 file and the Node CLI — which +// has no equivalent concept — says nothing. Before this was normalized away, +// it failed 32 of the differential scenarios on Linux while every one of them +// passed on a developer's macOS machine. +func TestDiffIgnoresTheAmbientKeychainFallbackNotice(t *testing.T) { + scenario := &Scenario{Argv: []string{"app", "list"}} + node := &RunResult{ExitCode: 0, Stdout: "same\n"} + goResult := &RunResult{ + ExitCode: 0, + Stdout: "same\n", + Stderr: "warning: OS keyring unavailable; storing credentials in /home/runner/.config/vip/credentials.json (0600)\n", + } + + d, err := Diff(scenario, node, goResult) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if !d.Equal { + t.Errorf("keychain fallback notice must not count as a divergence; got %+v", d) + } +} + +// The ambient rule is deliberately narrow. A real message on stderr — including +// one that merely mentions the keyring — must still diverge, or the normalizer +// would be hiding the very thing the harness exists to catch. +func TestDiffStillReportsRealStderrDivergence(t *testing.T) { + scenario := &Scenario{Argv: []string{"app", "list"}} + node := &RunResult{ExitCode: 0, Stdout: "same\n"} + goResult := &RunResult{ + ExitCode: 0, + Stdout: "same\n", + Stderr: "Error: could not read credentials from the OS keyring\n", + } + + d, err := Diff(scenario, node, goResult) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if d.Equal { + t.Error("a real stderr message must still diverge") + } + if d.StderrDelta == "" { + t.Error("expected StderrDelta to describe the divergence") + } +} + +// The notice must be stripped before the drift signature is computed, or an +// accepted divergence would fingerprint differently on Linux than on macOS and +// every expected_drift scenario would fail on exactly one of the two. +func TestAmbientNoticeIsStrippedBeforeTheDriftSignature(t *testing.T) { + mk := func(stderr string) (*Scenario, *RunResult, *RunResult) { + s := &Scenario{Argv: []string{"app", "list"}} + return s, + &RunResult{ExitCode: 0, Stdout: "node\n"}, + &RunResult{ExitCode: 1, Stdout: "go\n", Stderr: stderr} + } + + clean, a1, b1 := mk("") + noisy, a2, b2 := mk("warning: OS keyring unavailable; storing credentials in /home/runner/.config/vip/credentials.json (0600)\n") + + // Capture the signature each case produces by asserting against a wrong one. + sig := func(s *Scenario, a, b *RunResult) string { + s.ExpectedDrift = &ExpectedDrift{Reason: "x", Signature: strings.Repeat("0", 64)} + _, err := Diff(s, a, b) + if err == nil { + t.Fatal("expected a signature mismatch to read the actual signature from") + } + _, actual, found := strings.Cut(err.Error(), "actual=") + if !found { + t.Fatalf("unexpected error shape: %v", err) + } + return actual + } + + if got, want := sig(noisy, a2, b2), sig(clean, a1, b1); got != want { + t.Errorf("signature differs with the ambient notice present:\n with = %s\nwithout = %s", got, want) + } +} diff --git a/internal/parity/differential_test.go b/internal/parity/differential_test.go new file mode 100644 index 000000000..c003c410b --- /dev/null +++ b/internal/parity/differential_test.go @@ -0,0 +1,166 @@ +//go:build parity + +package parity + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "sync" + "sync/atomic" + "testing" +) + +// The shared Node-vs-Go rig. +// +// WHY A SINGLETON +// +// Every differential scenario needs the Node CLI authenticated, and since +// trunk 4.1.0 the ONLY way to authenticate it is a real credential-store write +// (Token.get reads getKeychain() and nothing else; there has never been an env +// escape hatch — see keychain.go). Node derives the service name from +// API_HOST, so a per-scenario httptest server means a per-scenario service +// name: 30-odd credentials created and destroyed per run, 30-odd chances to +// leak one, and 30-odd chances for a SIGKILL to strand one. A previous +// incarnation of that pattern left 727 orphaned entries to be purged by hand. +// +// So the whole test binary shares ONE httptest server, therefore ONE API_HOST, +// therefore ONE seeded credential — written once before the first differential +// runs and deleted once after the last one finishes. The count of credentials +// a full run creates is a constant, not a function of how many scenarios +// exist, which is the property that keeps "before == after" true as scenarios +// are added. +// +// The cost is that the server's handler has to change per scenario. It is held +// in an atomic and swapped by each subtest; differential subtests therefore +// MUST NOT call t.Parallel(). TestDifferentialScenariosAreSequential documents +// and does not enforce that — the swap itself is race-free, but two scenarios +// interleaving would serve each other's recordings. +type differentialRig struct { + nodeBin string + goBin string + srv *httptest.Server + handler atomic.Pointer[http.Handler] + token string + + // binDir is removed at teardown; the built binary has to outlive whichever + // test happened to construct the rig. + binDir string +} + +var ( + rigOnce sync.Once + rig *differentialRig + rigSkip string + rigFatal error +) + +// differentialAvailable returns the shared rig, or a reason to skip. +// +// Callers must treat a non-empty skip reason as a LOUD skip (LoudSkip), never +// as a pass: on a host where the Node CLI cannot be run or its credential +// store cannot be driven, the differential compares nothing, and a silent +// green there is precisely the failure this whole area exists to remove. +func differentialAvailable(t *testing.T) (*differentialRig, string) { + t.Helper() + rigOnce.Do(setupDifferentialRig) + if rigFatal != nil { + // A harness bug (a bad build, a service-name derivation that has + // drifted from Node's) is not a hostile environment. Fail. + t.Fatalf("differential rig: %v", rigFatal) + } + return rig, rigSkip +} + +func setupDifferentialRig() { + node := ResolveNodeVipBin(os.Getenv("NODE_VIP_BIN"), DefaultNodeVipBinProbe()) + if !node.Ready { + rigSkip = node.Reason + return + } + + binDir, err := os.MkdirTemp("", "vip-next-differential") + if err != nil { + rigFatal = fmt.Errorf("temp dir for the Go binary: %w", err) + return + } + goBin, err := buildVipNextInto(binDir, "test", "test") + if err != nil { + _ = os.RemoveAll(binDir) + rigFatal = err + return + } + + r := &differentialRig{nodeBin: node.Path, goBin: goBin, binDir: binDir, token: FixtureToken()} + r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + h := r.handler.Load() + if h == nil { + // A request outside any scenario is a harness bug, not data. + // Answer with something that cannot be mistaken for a payload. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"errors":[{"message":"parity: no scenario handler installed"}]}`)) + return + } + (*h).ServeHTTP(w, req) + })) + + // Publish the rig BEFORE seeding, so teardown collects a partial write. + rig = r + + switch err := SeedNodeKeychainToken(r.nodeBin, r.srv.URL, r.token); { + case errors.Is(err, ErrKeychainSeedMismatch): + rigFatal = fmt.Errorf("seeding the Node credential: %w", err) + case err != nil: + rigSkip = "the credential store could not be driven, and the Node CLI has no other " + + "way to authenticate (it has never had an environment escape hatch). Details: " + + err.Error() + } +} + +// teardownDifferentialRig is called from TestMain AFTER m.Run(). It is the +// only place the shared credential is removed, and it runs whether the suite +// passed, failed, or panicked out of an individual test. +func teardownDifferentialRig() { + if rig == nil { + return + } + if rig.srv != nil { + rig.srv.Close() + } + if err := CleanupParityCredentials(rig.nodeBin, rig.srv.URL); err != nil { + fmt.Fprintf(os.Stderr, "parity differential: credential cleanup failed: %v\n", err) + } + if rig.binDir != "" { + _ = os.RemoveAll(rig.binDir) + } +} + +// serve installs h as the handler for the rest of the current subtest and +// restores the previous one afterwards. +func (r *differentialRig) serve(t *testing.T, h http.Handler) { + t.Helper() + previous := r.handler.Load() + r.handler.Store(&h) + t.Cleanup(func() { r.handler.Store(previous) }) +} + +// scenarioEnv is the environment both binaries get: the scenario's own +// overrides, plus the shared API_HOST, plus the Go-side token. +// +// The two CLIs receive the SAME credential by two different routes — Node from +// the seeded store, Go from VIP_TOKEN_OVERRIDE under GO_ENV=test. Minting it +// once and handing the same string to both is what keeps these scenarios a +// test of command output rather than of credential plumbing, and it is what +// lets the ~50 mock-only scenarios stay off the credential store entirely. +func (r *differentialRig) scenarioEnv(s *Scenario) map[string]string { + env := map[string]string{} + for k, v := range s.Env { + env[k] = v + } + env["API_HOST"] = r.srv.URL + env["VIP_TOKEN_OVERRIDE"] = r.token + return env +} diff --git a/internal/parity/env.go b/internal/parity/env.go new file mode 100644 index 000000000..033a07877 --- /dev/null +++ b/internal/parity/env.go @@ -0,0 +1,176 @@ +//go:build parity + +package parity + +import ( + "encoding/base64" + "os" + "sort" + "strings" + "time" + + json "encoding/json/v2" +) + +// FixtureAPIHost is the API host every fixture subprocess gets unless the +// scenario points it at its own httptest server. Port 1 on loopback is +// closed on every supported platform, so a scenario that forgets to stand up +// a mock fails with a connection error instead of quietly talking to the +// real production API with whatever credential happened to be lying around. +const FixtureAPIHost = "http://127.0.0.1:1" + +// fixtureTokenUserID is the `id` claim in FixtureToken. Scenario tests that +// mint their own token use the same id so recordings stay interchangeable. +const fixtureTokenUserID = 42 + +// scenarioEnvPassthrough is the ONLY set of parent variables carried into a +// fixture subprocess. Everything else — credentials, API hosts, proxies, +// XDG overrides, DEBUG namespaces, colour knobs — is dropped, because the +// suite must produce identical results on a developer laptop with live +// credentials and in a bare CI container with none. +// +// Keep this list to variables the operating system and the language runtimes +// need in order to start a process at all. If a scenario needs anything else, +// it sets it explicitly. +var scenarioEnvPassthrough = []string{ + // POSIX process basics. PATH is required for the `#!/usr/bin/env node` + // shebang on the Node binary; HOME for per-user config/credential lookup. + "PATH", + "HOME", + "TMPDIR", + "TMP", + "TEMP", + "USER", + "LOGNAME", + "SHELL", + "LANG", + "LC_ALL", + + // Windows equivalents: without these a child process cannot resolve + // system DLLs, the user profile, or executable extensions. + "SystemRoot", + "SystemDrive", + "ComSpec", + "PATHEXT", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "WINDIR", + "APPDATA", + "LOCALAPPDATA", + "PROGRAMDATA", + "ProgramFiles", + "ProgramFiles(x86)", +} + +// scenarioEnvPinned is the explicitly-constructed base. These values are the +// same on every machine; a scenario's own Env map overrides any of them. +// +// - API_HOST dead loopback (see FixtureAPIHost) +// - VIP_TOKEN_OVERRIDE a deterministic fake JWT that authenticates the GO +// binary without touching the host keychain. It does nothing for Node and +// never did: the variable has never existed upstream (`git log --all -S` on +// Automattic/vip returns zero commits), Token.get() reads getKeychain() and +// nothing else. Scenarios that run the real Node binary seed a credential +// instead — see keychain.go and differential_test.go. Keeping the override +// here is what stops the ~50 mock-only scenarios from having to write +// credentials at all. +// - NODE_ENV/GO_ENV test mode: suppresses Node's update-notifier network +// call, and is the gate Go still applies to the token override +// (internal/auth/store.go tokenOverride). +// - DO_NOT_TRACK no telemetry from either CLI. Note this does NOT stop +// Node from creating its "<service>-uuid" keychain entry: trackEvent calls +// Token.uuid() (src/lib/tracker.ts:55) before any opt-out check, which is +// why that name is in ParityKeychainServices. +func scenarioEnvPinned() map[string]string { + return map[string]string{ + "API_HOST": FixtureAPIHost, + "VIP_TOKEN_OVERRIDE": FixtureToken(), + "NODE_ENV": "test", + "GO_ENV": "test", + "DO_NOT_TRACK": "1", + } +} + +// ScenarioEnv builds the environment for a fixture-suite subprocess. +// +// It is the fixture-suite counterpart of BuildParkerEnv: a scrubbed, +// explicitly-constructed base rather than an inherited one. Composition order +// is passthrough allowlist → pinned base → caller overrides, so a scenario can +// always win. +// +// Pass os.Environ() as parent. Anything not in scenarioEnvPassthrough and not +// in the pinned base is ABSENT from the result — absence, not an empty value, +// because the CLIs use LookupEnv-style presence checks in places. +func ScenarioEnv(parent []string, overrides map[string]string) []string { + pinned := scenarioEnvPinned() + + allow := make(map[string]bool, len(scenarioEnvPassthrough)) + for _, key := range scenarioEnvPassthrough { + allow[key] = true + } + + out := make(map[string]string, len(allow)+len(pinned)+len(overrides)) + for _, kv := range parent { + key, value, ok := strings.Cut(kv, "=") + if !ok || !allow[key] { + continue + } + out[key] = value + } + for key, value := range pinned { + out[key] = value + } + for key, value := range overrides { + out[key] = value + } + + keys := make([]string, 0, len(out)) + for key := range out { + keys = append(keys, key) + } + sort.Strings(keys) + + env := make([]string, 0, len(keys)) + for _, key := range keys { + env = append(env, key+"="+out[key]) + } + return env +} + +// FixtureEnv is the call-site form of ScenarioEnv: it takes the real process +// environment as the parent and applies the scenario's overrides. +// +// Every fixture scenario builds its subprocess environment through this +// function. os.Environ() must not appear anywhere else in the package — +// TestNoAmbientEnvInheritanceInScenarios enforces that, because a single bare +// os.Environ() re-opens the hole where the suite passes on a laptop with live +// credentials and fails in a bare CI container. +func FixtureEnv(overrides map[string]string) []string { + return ScenarioEnv(os.Environ(), overrides) +} + +// FixtureToken mints the deterministic credential pinned into every fixture +// subprocess: an unsigned JWT with a fixed user id and a one-hour validity +// window. Both CLIs only decode the payload (they never verify the +// signature), so this is enough to get past the auth wall without touching a +// keychain or a real API. +// +// The exp claim is relative to now, so the token cannot rot in the repo. +func FixtureToken() string { + now := time.Now() + header, err := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + if err != nil { + return "" + } + claims, err := json.Marshal(map[string]any{ + "id": fixtureTokenUserID, + "iat": now.Add(-time.Hour).Unix(), + "exp": now.Add(time.Hour).Unix(), + }) + if err != nil { + return "" + } + enc := base64.RawURLEncoding + return enc.EncodeToString(header) + "." + enc.EncodeToString(claims) + "." +} diff --git a/internal/parity/env_test.go b/internal/parity/env_test.go new file mode 100644 index 000000000..e8fca6da2 --- /dev/null +++ b/internal/parity/env_test.go @@ -0,0 +1,211 @@ +//go:build parity + +package parity + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// hostileParent is the environment of a developer laptop that has live +// credentials, a corporate proxy, and personal overrides exported — exactly +// the ambient state the fixture suite must be immune to. +func hostileParent() []string { + return []string{ + "PATH=/usr/bin:/bin", + "HOME=/Users/developer", + "VIP_TOKEN_OVERRIDE=live.laptop.jwt", + "WPVIP_DEPLOY_TOKEN=live-deploy-key", + "API_HOST=https://api.wpvip.com", + "HTTP_PROXY=http://corp-proxy:8080", + "HTTPS_PROXY=http://corp-proxy:8080", + "ALL_PROXY=socks5://corp-proxy:1080", + "VIP_PROXY=socks5://corp-proxy:1080", + "SOCKS_PROXY=socks5://corp-proxy:1080", + "VIP_USE_SYSTEM_PROXY=1", + "http_proxy=http://corp-proxy:8080", + "https_proxy=http://corp-proxy:8080", + "all_proxy=socks5://corp-proxy:1080", + "NODE_ENV=production", + "DO_NOT_TRACK=0", + "NO_COLOR=1", + "XDG_DATA_HOME=/Users/developer/Library/Application Support", + "VIP_SEARCH_REPLACE_BIN=/opt/homebrew/bin/go-search-replace", + "DEBUG=*", + "SOME_PERSONAL_VAR=1", + } +} + +func TestScenarioEnvPinsCredentialsRegardlessOfAmbient(t *testing.T) { + got := envMap(ScenarioEnv(hostileParent(), nil)) + + if got["VIP_TOKEN_OVERRIDE"] == "live.laptop.jwt" { + t.Error("ambient VIP_TOKEN_OVERRIDE leaked into the fixture environment") + } + if got["VIP_TOKEN_OVERRIDE"] == "" { + t.Error("fixture environment must pin a deterministic token so the Go binary never falls back " + + "to the host keychain (Node 4.1.0 ignores this variable; TestWhoamiBaselineParity seeds " + + "an ephemeral keychain entry for it instead)") + } + if _, present := got["WPVIP_DEPLOY_TOKEN"]; present { + t.Errorf("WPVIP_DEPLOY_TOKEN must be absent unless a scenario sets it; got %q", got["WPVIP_DEPLOY_TOKEN"]) + } +} + +func TestScenarioEnvDropsAmbientProxiesAndHost(t *testing.T) { + got := envMap(ScenarioEnv(hostileParent(), nil)) + + for _, key := range []string{ + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "VIP_PROXY", "SOCKS_PROXY", + "VIP_USE_SYSTEM_PROXY", "http_proxy", "https_proxy", "all_proxy", + } { + if _, present := got[key]; present { + t.Errorf("proxy variable %s leaked into the fixture environment (value %q)", key, got[key]) + } + } + if got["API_HOST"] == "https://api.wpvip.com" { + t.Error("ambient API_HOST leaked: a fixture scenario could reach the real API") + } + if !strings.Contains(got["API_HOST"], "127.0.0.1") { + t.Errorf("API_HOST must default to a dead loopback address, got %q", got["API_HOST"]) + } +} + +func TestScenarioEnvDropsUnlistedAmbientVariables(t *testing.T) { + got := envMap(ScenarioEnv(hostileParent(), nil)) + + for _, key := range []string{ + "NO_COLOR", "XDG_DATA_HOME", "VIP_SEARCH_REPLACE_BIN", "DEBUG", "SOME_PERSONAL_VAR", + } { + if _, present := got[key]; present { + t.Errorf("unlisted ambient variable %s leaked into the fixture environment (value %q)", key, got[key]) + } + } + // The OS-level allowlist must survive: the subprocess needs to find its + // interpreter and a home directory. + if got["PATH"] != "/usr/bin:/bin" { + t.Errorf("PATH = %q, want the parent's value", got["PATH"]) + } + if got["HOME"] != "/Users/developer" { + t.Errorf("HOME = %q, want the parent's value", got["HOME"]) + } +} + +func TestScenarioEnvPinsTestModeAndTelemetryOff(t *testing.T) { + got := envMap(ScenarioEnv(hostileParent(), nil)) + + if got["NODE_ENV"] != "test" { + t.Errorf("NODE_ENV = %q, want test (Node gates its update-notifier on it; Go accepts it "+ + "as an alias for GO_ENV when gating VIP_TOKEN_OVERRIDE)", got["NODE_ENV"]) + } + if got["DO_NOT_TRACK"] != "1" { + t.Errorf("DO_NOT_TRACK = %q, want 1", got["DO_NOT_TRACK"]) + } +} + +func TestScenarioEnvOverridesWinOverPinnedValues(t *testing.T) { + got := envMap(ScenarioEnv(hostileParent(), map[string]string{ + "API_HOST": "http://127.0.0.1:65000", + "VIP_TOKEN_OVERRIDE": "scenario.jwt", + "WPVIP_DEPLOY_TOKEN": "deploy-tok", + "NO_COLOR": "1", + })) + + if got["API_HOST"] != "http://127.0.0.1:65000" { + t.Errorf("API_HOST = %q, want the scenario override", got["API_HOST"]) + } + if got["VIP_TOKEN_OVERRIDE"] != "scenario.jwt" { + t.Errorf("VIP_TOKEN_OVERRIDE = %q, want the scenario override", got["VIP_TOKEN_OVERRIDE"]) + } + if got["WPVIP_DEPLOY_TOKEN"] != "deploy-tok" { + t.Errorf("WPVIP_DEPLOY_TOKEN = %q, want the scenario override", got["WPVIP_DEPLOY_TOKEN"]) + } + if got["NO_COLOR"] != "1" { + t.Errorf("NO_COLOR = %q, want the scenario override", got["NO_COLOR"]) + } +} + +// TestScenarioEnvIsAmbientIndependent is the property the whole slice exists +// for: a laptop with live credentials and a bare CI container must produce +// byte-identical subprocess environments. +func TestScenarioEnvIsAmbientIndependent(t *testing.T) { + bareCI := []string{"PATH=/usr/bin:/bin", "HOME=/Users/developer"} + + overrides := map[string]string{"API_HOST": "http://127.0.0.1:65000"} + laptop := envMap(ScenarioEnv(hostileParent(), overrides)) + ci := envMap(ScenarioEnv(bareCI, overrides)) + + // The pinned token is minted per call (it carries a live exp claim), so + // compare everything else exactly and assert the token is merely present. + for _, m := range []map[string]string{laptop, ci} { + if m["VIP_TOKEN_OVERRIDE"] == "" { + t.Fatal("expected a pinned token in both environments") + } + delete(m, "VIP_TOKEN_OVERRIDE") + } + if len(laptop) != len(ci) { + t.Fatalf("laptop env has %d vars, CI env has %d: %v vs %v", len(laptop), len(ci), laptop, ci) + } + for k, v := range laptop { + if ci[k] != v { + t.Errorf("%s: laptop=%q CI=%q", k, v, ci[k]) + } + } +} + +func TestFixtureTokenIsAcceptedByTheCLI(t *testing.T) { + tok := FixtureToken() + if strings.Count(tok, ".") != 2 { + t.Fatalf("FixtureToken() = %q, want a three-segment JWT", tok) + } + // Two calls must both be valid; they need not be byte-identical. + if other := FixtureToken(); strings.Count(other, ".") != 2 { + t.Fatalf("second FixtureToken() = %q, want a three-segment JWT", other) + } +} + +// TestNoAmbientEnvInheritanceInScenarios is the permanent regression guard for +// this slice. Every fixture scenario must construct its subprocess environment +// through ScenarioEnv; a bare os.Environ() anywhere else silently re-opens the +// hole where the suite passes on a developer laptop and fails in CI. +func TestNoAmbientEnvInheritanceInScenarios(t *testing.T) { + // env.go owns the allowlist; harness_test.go uses os.Environ() to invoke + // `go build` (a toolchain call, not a CLI invocation); parker_live_test.go + // is the live Parker gate and scrubs through BuildParkerEnv. + allowed := map[string]bool{ + "env.go": true, + "env_test.go": true, + "harness_test.go": true, + "parker_live_test.go": true, + "parker_test.go": true, + } + + entries, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + if len(entries) == 0 { + t.Fatal("no Go sources found — did the package move?") + } + for _, path := range entries { + base := filepath.Base(path) + if allowed[base] { + continue + } + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + for i, line := range strings.Split(string(src), "\n") { + // Comments may legitimately name os.Environ() while explaining + // why it is not used; only flag code. + code, _, _ := strings.Cut(line, "//") + if strings.Contains(code, "os.Environ()") { + t.Errorf("%s:%d inherits the ambient environment; use FixtureEnv instead:\n\t%s", + base, i+1, strings.TrimSpace(line)) + } + } + } +} diff --git a/internal/parity/envvar_mutation_scenario_test.go b/internal/parity/envvar_mutation_scenario_test.go new file mode 100644 index 000000000..ddd843534 --- /dev/null +++ b/internal/parity/envvar_mutation_scenario_test.go @@ -0,0 +1,188 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// envvarMutationMux dispatches GraphQL requests for the M6 envvar set/delete +// scenarios. +// +// It must answer BOTH CLIs — these recordings back real Node-vs-Go +// differentials (TestSurfaceDifferentialParity), not just vip-next. +// +// Operation → file mapping: +// +// ResolveAppByName / ResolveAppByID -> resolve-app.json (Go) +// App -> resolve-app.json (Node, src/lib/api/app.ts:46) +// AddEnvironmentVariable -> add.json (both) +// DeleteEnvironmentVariable -> delete.json (both) +// +// The two mutation names happen to agree; the app resolution does not. +// +// Node reads `app.organization.id` when it assembles this command's tracking +// params (src/bin/vip-config-envvar-set.js:47, -delete.js likewise), BEFORE the +// command body runs, so a resolve-app.json without an `organization` object +// kills the real Node CLI with "TypeError: Cannot read properties of undefined +// (reading 'id')" and every scenario in the family "diverges" for a reason that +// is purely the fixture's. Keep `organization` present in every recording. +// +// Missing files fall back to {"data":null}. The mutation hit counters are +// returned so cancel-scenarios can assert the mutation was NOT called. +func envvarMutationMux(t *testing.T, recordingDir string) (http.Handler, func() (add, del int32)) { + t.Helper() + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + maybeRead := func(name string) []byte { + b, err := os.ReadFile(base + name) + if err != nil { + return nil + } + return b + } + + resolveAppBody := maybeRead("resolve-app.json") + addBody := maybeRead("add.json") + deleteBody := maybeRead("delete.json") + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var addHits, delHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, resolveAppBody) + case strings.Contains(s, `"operationName":"AddEnvironmentVariable"`): + atomic.AddInt32(&addHits, 1) + serve(w, addBody) + case strings.Contains(s, `"operationName":"DeleteEnvironmentVariable"`): + atomic.AddInt32(&delHits, 1) + serve(w, deleteBody) + default: + serve(w, nil) + } + }) + hits := func() (int32, int32) { + return atomic.LoadInt32(&addHits), atomic.LoadInt32(&delHits) + } + return mux, hits +} + +// envvarMutationPrefixes are YAML name prefixes that identify M6 envvar +// set/delete scenarios. Kept separate from m5Prefixes so we can extend the +// mux independently — the M6 mux understands Add/DeleteEnvironmentVariable +// while the M5 mux does not. +var envvarMutationPrefixes = []string{ + "envvar-set-", + "envvar-delete-", +} + +func isEnvvarMutationScenario(name string) bool { + for _, p := range envvarMutationPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// envvarCancelScenarios names the scenarios where the mutation MUST NOT +// fire — used as an extra wire-level assertion beyond the exit-code check. +var envvarCancelScenarios = map[string]bool{ + "envvar-set-prod-cancel": true, + "envvar-set-newrelic-blocked": true, + "envvar-set-invalid-name": true, + "envvar-delete-prod-cancel": true, + "envvar-delete-typed-mismatch": true, +} + +// TestM6EnvvarMutationScenarios discovers every YAML matching +// envvar-set-* / envvar-delete-* and runs it against envvarMutationMux. +func TestM6EnvvarMutationScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + var scenarios []string + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if isEnvvarMutationScenario(base) { + scenarios = append(scenarios, path) + } + } + if len(scenarios) == 0 { + t.Fatal("no M6 envvar mutation scenarios found — testdata may have moved") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range scenarios { + scenarioName := strings.TrimSuffix(filepath.Base(path), ".yaml") + + t.Run(scenarioName, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion for %s", scenario.ExpectedDrift.Reason, scenarioName) + return + } + + mux, hits := envvarMutationMux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run(%s): %v", scenarioName, err) + } + + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("%s: exit code = %d, want %d\n stderr: %s\n stdout: %s", + scenarioName, res.ExitCode, scenario.Expect.ExitCode, + res.Stderr, res.Stdout) + } + + // Wire-level assertion: cancel scenarios MUST NOT fire the mutation. + if envvarCancelScenarios[scenarioName] { + add, del := hits() + if add != 0 || del != 0 { + t.Errorf("%s: cancel scenario must not call add/delete; got add=%d del=%d", scenarioName, add, del) + } + } + }) + } +} diff --git a/internal/parity/harness.go b/internal/parity/harness.go new file mode 100644 index 000000000..d35301bff --- /dev/null +++ b/internal/parity/harness.go @@ -0,0 +1,28 @@ +//go:build parity + +package parity + +import ( + "fmt" +) + +// CompareBinaries runs Argv against binA and binB under the same env, +// applies the scenario's normalizers, and diffs. +// +// The subprocess env is the scrubbed fixture base (see FixtureEnv) plus any +// Scenario.Env entries, which win on conflict. It is deliberately NOT +// os.Environ(): a differential Node-vs-Go run inherits ambient credentials +// and proxies otherwise, which makes the comparison depend on whose laptop +// it ran on. +func CompareBinaries(s *Scenario, binA, binB string) (*DiffResult, error) { + env := FixtureEnv(s.Env) + resA, err := Run(RunSpec{Binary: binA, Argv: s.Argv, Env: env}) + if err != nil { + return nil, fmt.Errorf("run a (%s): %w", binA, err) + } + resB, err := Run(RunSpec{Binary: binB, Argv: s.Argv, Env: env}) + if err != nil { + return nil, fmt.Errorf("run b (%s): %w", binB, err) + } + return Diff(s, resA, resB) +} diff --git a/internal/parity/harness_test.go b/internal/parity/harness_test.go new file mode 100644 index 000000000..80c0bb840 --- /dev/null +++ b/internal/parity/harness_test.go @@ -0,0 +1,62 @@ +//go:build parity + +package parity + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func buildVipNextWithVersion(t *testing.T, ver, commit string) string { + t.Helper() + bin, err := buildVipNextInto(t.TempDir(), ver, commit) + if err != nil { + t.Fatal(err) + } + return bin +} + +// buildVipNextInto is the *testing.T-free form, for the shared differential +// rig, whose binary has to outlive the test that happened to create it. It +// lives here because harness_test.go is the file allowed to reach for the +// ambient environment: `go build` is a toolchain call needing PATH, HOME and +// the build caches, not a CLI invocation whose environment is under test. +func buildVipNextInto(dir, ver, commit string) (string, error) { + bin := filepath.Join(dir, "vip-next") + cmd := exec.Command("go", "build", + "-buildvcs=false", + "-ldflags=-X github.com/Automattic/vip/internal/version.Version="+ver+ + " -X github.com/Automattic/vip/internal/version.Commit="+commit, + "-o", bin, + "../../cmd/vip-next") + cmd.Env = os.Environ() + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("build vip-next: %w\n%s", err, stderr.String()) + } + return bin, nil +} + +func TestVersionSmokeSelfDiff(t *testing.T) { + // Two builds with different version metadata. + binA := buildVipNextWithVersion(t, "1.0.0", "aaaaaaa") + binB := buildVipNextWithVersion(t, "9.9.9", "fffffff") + + s, err := LoadScenario("../../testdata/parity/version-smoke.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + + d, err := CompareBinaries(s, binA, binB) + if err != nil { + t.Fatalf("CompareBinaries: %v", err) + } + if !d.Equal { + t.Errorf("self-diff after normalization should be Equal; got %+v", d) + } +} diff --git a/internal/parity/import_media_scenario_test.go b/internal/parity/import_media_scenario_test.go new file mode 100644 index 000000000..3788fc0e7 --- /dev/null +++ b/internal/parity/import_media_scenario_test.go @@ -0,0 +1,207 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" +) + +// importMediaMux dispatches GraphQL requests for the M7b media-import + +// validate-files scenarios. Per-scenario recordings override the shared +// fixtures (import-media-shared/) file-by-file: +// +// ResolveAppByName / ResolveAppByID -> resolve-app.json +// ImportSQLEnvInfo -> env-info.json (banner domain) +// StartMediaImport -> start-media-import.json (counted) +// AbortMediaImport -> abort-media-import.json (counted) +// MediaImportProgress -> progress.json +// MediaImportConfig -> config.json +func importMediaMux(t *testing.T, recordingDir string) (http.Handler, func() (start, abort int32)) { + t.Helper() + shared := "../../testdata/parity/recordings/import-media-shared/" + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + read := func(name string) []byte { + if b, err := os.ReadFile(base + name); err == nil { + return b + } + if b, err := os.ReadFile(shared + name); err == nil { + return b + } + return nil + } + + resolveAppBody := read("resolve-app.json") + envInfoBody := read("env-info.json") + startBody := read("start-media-import.json") + abortBody := read("abort-media-import.json") + progressBody := read("progress.json") + configBody := read("config.json") + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var startHits, abortHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + // `App` is Node's app resolution (src/lib/api/app.ts:46,69). Node folds + // the environment detail Go fetches separately (ImportSQLEnvInfo) into + // this one query, so resolve-app.json has to carry those fields too. + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, resolveAppBody) + case strings.Contains(s, `"operationName":"ImportSQLEnvInfo"`): + serve(w, envInfoBody) + case strings.Contains(s, `"operationName":"StartMediaImport"`): + atomic.AddInt32(&startHits, 1) + serve(w, startBody) + case strings.Contains(s, `"operationName":"AbortMediaImport"`): + atomic.AddInt32(&abortHits, 1) + serve(w, abortBody) + case strings.Contains(s, `"operationName":"MediaImportProgress"`): + serve(w, progressBody) + case strings.Contains(s, `"operationName":"MediaImportConfig"`): + serve(w, configBody) + default: + serve(w, nil) + } + }) + hits := func() (int32, int32) { + return atomic.LoadInt32(&startHits), atomic.LoadInt32(&abortHits) + } + return mux, hits +} + +// TestM7bImportMediaScenarios discovers every YAML matching +// import-media-* and import-validate-files-* and runs the Go binary +// against the stubbed API. +func TestM7bImportMediaScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + mediaEntries, err := filepath.Glob(yamlDir + "/import-media-*.yaml") + if err != nil { + t.Fatalf("glob: %v", err) + } + vfEntries, err := filepath.Glob(yamlDir + "/import-validate-files-*.yaml") + if err != nil { + t.Fatalf("glob: %v", err) + } + entries := append(mediaEntries, vfEntries...) + sort.Strings(entries) + if len(entries) == 0 { + t.Fatal("no import-media scenarios found — testdata moved?") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range entries { + name := strings.TrimSuffix(filepath.Base(path), ".yaml") + t.Run(name, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion", scenario.ExpectedDrift.Reason) + return + } + + mux, hits := importMediaMux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("exit=%d, want %d\n stderr: %s\n stdout: %s", + res.ExitCode, scenario.Expect.ExitCode, res.Stderr, res.Stdout) + } + + combined := res.Stdout + res.Stderr + startHits, abortHits := hits() + switch name { + case "import-media-help": + for _, flag := range []string{ + "--exportFileErrorsToJson", "--saveErrorLog", + "--overwriteExistingFiles", "--importIntermediateImages", + } { + if !strings.Contains(combined, flag) { + t.Errorf("help missing %s:\n%s", flag, combined) + } + } + if !strings.Contains(combined, "status") || !strings.Contains(combined, "abort") { + t.Errorf("help missing subcommands:\n%s", combined) + } + case "import-media-invalid-archive": + if !strings.Contains(combined, "Invalid local archive provided:") || + !strings.Contains(combined, ".tar.gz, .tgz, .zip") { + t.Errorf("missing invalid-archive block:\n%s", combined) + } + if startHits != 0 { + t.Errorf("StartMediaImport fired %d times, want 0", startHits) + } + case "import-media-url-completed": + if !strings.Contains(combined, "Importing archive from: https://example.com/uploads.zip") { + t.Errorf("missing banner:\n%s", combined) + } + if startHits != 1 { + t.Errorf("StartMediaImport fired %d times, want 1", startHits) + } + case "import-media-status-completed": + if !strings.Contains(combined, "COMPLETED") { + t.Errorf("missing COMPLETED status:\n%s", combined) + } + case "import-media-status-failed": + if !strings.Contains(combined, "Import failed at status: ") || + !strings.Contains(combined, "RUNNING") || + !strings.Contains(combined, "disk full") { + t.Errorf("missing failure block:\n%s", combined) + } + case "import-media-abort-noninteractive": + if abortHits != 0 { + t.Errorf("AbortMediaImport fired %d times, want 0 (declined confirm)", abortHits) + } + case "import-validate-files-not-dir": + if !strings.Contains(combined, "The given path is not a directory. Provide a valid directory path.") { + t.Errorf("missing not-a-directory error:\n%s", combined) + } + case "import-validate-files-clean": + if !strings.Contains(combined, "PASS") || + !strings.Contains(combined, "2 files total") { + t.Errorf("missing summary:\n%s", combined) + } + if strings.Contains(combined, "ERROR") { + t.Errorf("clean fixture produced ERROR badges:\n%s", combined) + } + } + }) + } +} diff --git a/internal/parity/import_sql_scenario_test.go b/internal/parity/import_sql_scenario_test.go new file mode 100644 index 000000000..e406e7ea3 --- /dev/null +++ b/internal/parity/import_sql_scenario_test.go @@ -0,0 +1,198 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" +) + +// importSQLMux dispatches GraphQL requests for the M7a import-sql +// scenarios. Per-scenario recordings override the shared fixtures +// (import-sql-shared/) file-by-file: +// +// ResolveAppByName / ResolveAppByID -> resolve-app.json +// ImportSQLEnvInfo -> env-info.json +// AppMultiSiteCheck -> multisite.json +// ImportSQLProgress -> progress.json +// StartImport -> start-import.json (hit-counted; +// gate/abort scenarios assert 0) +func importSQLMux(t *testing.T, recordingDir string) (http.Handler, func() int32) { + t.Helper() + shared := "../../testdata/parity/recordings/import-sql-shared/" + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + read := func(name string) []byte { + if b, err := os.ReadFile(base + name); err == nil { + return b + } + if b, err := os.ReadFile(shared + name); err == nil { + return b + } + return nil + } + + resolveAppBody := read("resolve-app.json") + envInfoBody := read("env-info.json") + multisiteBody := read("multisite.json") + progressBody := read("progress.json") + startImportBody := read("start-import.json") + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var startImportHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + // `App` is Node's app resolution (src/lib/api/app.ts:46,69). Node's + // appQuery for this command additionally selects launched, + // isK8sResident, syncProgress and importStatus — i.e. everything Go + // fetches in the separate ImportSQLEnvInfo round trip — so + // resolve-app.json must stay consistent with env-info.json or the two + // CLIs are answering questions about different worlds and the + // differential is meaningless. + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, resolveAppBody) + case strings.Contains(s, `"operationName":"ImportSQLEnvInfo"`): + serve(w, envInfoBody) + case strings.Contains(s, `"operationName":"AppMultiSiteCheck"`): + serve(w, multisiteBody) + case strings.Contains(s, `"operationName":"ImportSQLProgress"`): + serve(w, progressBody) + case strings.Contains(s, `"operationName":"StartImport"`): + atomic.AddInt32(&startImportHits, 1) + serve(w, startImportBody) + default: + serve(w, nil) + } + }) + return mux, func() int32 { return atomic.LoadInt32(&startImportHits) } +} + +// TestM7aImportSQLScenarios discovers every YAML matching import-sql-* +// (excluding the M6b import-validate-sql-* family) and runs the Go +// binary against the stubbed API. +func TestM7aImportSQLScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/import-sql-*.yaml") + if err != nil { + t.Fatalf("glob: %v", err) + } + sort.Strings(entries) + if len(entries) == 0 { + t.Fatal("no import-sql scenarios found — testdata moved?") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range entries { + name := strings.TrimSuffix(filepath.Base(path), ".yaml") + t.Run(name, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion", scenario.ExpectedDrift.Reason) + return + } + + mux, startImportHits := importSQLMux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("exit=%d, want %d\n stderr: %s\n stdout: %s", + res.ExitCode, scenario.Expect.ExitCode, res.Stderr, res.Stdout) + } + + combined := res.Stdout + res.Stderr + switch name { + case "import-sql-help": + for _, flag := range []string{ + "--skip-validate", "--search-replace", "--in-place", "--output", + "--skip-maintenance-mode", "--md5", "--header", "--skip-backup", + } { + if !strings.Contains(combined, flag) { + t.Errorf("help output missing %s:\n%s", flag, combined) + } + } + if !strings.Contains(combined, "status") { + t.Errorf("help output missing status subcommand:\n%s", combined) + } + case "import-sql-bad-extension": + if !strings.Contains(combined, "Invalid file extension. Please provide a .sql or .gz file.") { + t.Errorf("missing extension-gate message:\n%s", combined) + } + case "import-sql-invalid-md5": + if !strings.Contains(combined, "The provided MD5 hash is invalid. It should be a 32-character hexadecimal string.") { + t.Errorf("missing md5-gate message:\n%s", combined) + } + case "import-sql-validation-failure": + if !strings.Contains(combined, "SQL validation failed due to") || + !strings.Contains(combined, "--skip-validate") { + t.Errorf("missing import-mode validation report:\n%s", combined) + } + case "import-sql-in-progress": + if !strings.Contains(combined, "There is already an import in progress.") || + !strings.Contains(combined, "vip import sql status") { + t.Errorf("missing in-progress gate message:\n%s", combined) + } + case "import-sql-noninteractive-abort": + if !strings.Contains(combined, "The input did not match the expected environment label. Import aborted.") { + t.Errorf("missing abort message:\n%s", combined) + } + // The playbook must have rendered before the prompt. + if !strings.Contains(combined, "importing:") { + t.Errorf("missing playbook output:\n%s", combined) + } + case "import-sql-status-no-job": + if !strings.Contains(combined, "No import job found") { + t.Errorf("missing no-job message:\n%s", combined) + } + case "import-sql-status-completed": + if !strings.Contains(combined, "Success") || + !strings.Contains(combined, "Importing db") { + t.Errorf("missing completed status block:\n%s", combined) + } + } + + // No scenario in this family may fire StartImport — they all + // abort at a gate, a prompt, or are read-only status checks. + if hits := startImportHits(); hits != 0 { + t.Errorf("StartImport fired %d times, want 0", hits) + } + }) + } +} diff --git a/internal/parity/import_validate_sql_scenario_test.go b/internal/parity/import_validate_sql_scenario_test.go new file mode 100644 index 000000000..3f40fe993 --- /dev/null +++ b/internal/parity/import_validate_sql_scenario_test.go @@ -0,0 +1,68 @@ +//go:build parity + +package parity + +import ( + "path/filepath" + "sort" + "strings" + "testing" +) + +// TestM6bImportValidateSQLScenarios discovers every YAML matching +// import-validate-sql-* and runs the Go binary against it. Local-only +// validator: no mock GraphQL server needed. +func TestM6bImportValidateSQLScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/import-validate-sql-*.yaml") + if err != nil { + t.Fatalf("glob: %v", err) + } + sort.Strings(entries) + if len(entries) == 0 { + t.Fatal("no import-validate-sql scenarios found — testdata moved?") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range entries { + name := strings.TrimSuffix(filepath.Base(path), ".yaml") + t.Run(name, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion", scenario.ExpectedDrift.Reason) + return + } + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("exit=%d, want %d; stderr=%q stdout=%q", + res.ExitCode, scenario.Expect.ExitCode, res.Stderr, res.Stdout) + } + // Wire-level content assertions per scenario. + switch name { + case "import-validate-sql-clean": + if !strings.Contains(res.Stdout, "clean") { + t.Errorf("clean scenario stdout missing 'clean':\n%s", res.Stdout) + } + case "import-validate-sql-multisite-warn": + if !strings.Contains(res.Stdout, "multi-site") { + t.Errorf("multisite scenario stdout missing 'multi-site':\n%s", res.Stdout) + } + case "import-validate-sql-dangerous-stmt": + if !strings.Contains(res.Stdout, "DROP DATABASE") { + t.Errorf("dangerous-stmt scenario stdout missing DROP DATABASE finding:\n%s", res.Stdout) + } + } + }) + } +} diff --git a/internal/parity/keychain.go b/internal/parity/keychain.go new file mode 100644 index 000000000..5ff15a7c8 --- /dev/null +++ b/internal/parity/keychain.go @@ -0,0 +1,538 @@ +//go:build parity + +package parity + +// Keychain plumbing for the Node-vs-Go differential scenario. +// +// WHY THIS EXISTS +// +// The Node CLI has NEVER had an environment escape hatch for credentials. +// `Token.get()` reads the OS credential store and nothing else, so the only way +// to put an identity in front of the real Node binary is to write a real +// keychain entry. +// +// An earlier version of this comment said Node 4.1.0 "removed +// VIP_TOKEN_OVERRIDE". That was wrong. The variable never existed upstream: +// `git log --all -S VIP_TOKEN_OVERRIDE` on Automattic/vip returns ZERO commits. +// It had been hand-injected into this repo's vendored copy of +// src/lib/token.ts (4 lines, gated on NODE_ENV=test) so that the harness would +// authenticate — i.e. the reference implementation was edited to make the test +// pass. The 4.0.4 -> trunk sync deleted that local edit, which is what made the +// differential start failing. Do not reintroduce it. +// +// DOES THIS WORK IN CI? +// +// Yes, and it was measured rather than assumed. Node picks its backend at +// runtime: getKeychain() (src/lib/keychain.ts:12-23) constructs Secure, probes +// it with a throwaway getPassword, and falls back to Insecure — a configstore +// JSON file under $XDG_CONFIG_HOME/configstore/vip-go-cli.json — if anything +// throws. On linux/amd64 with node:22, exercising the REAL vendored +// dist/lib/keychain.js: +// +// - without libsecret: `require('@github/keytar')` throws at load in ~30ms; +// - with libsecret-1-0 installed and no D-Bus session (the shape of a GitHub +// Actions runner): the module loads, and the probe REJECTS in ~82ms with +// "Cannot spawn a message bus without a machine-id". +// +// Both land on Insecure, both in well under a second, and neither hangs. A +// seed written by one process was then read back by a SEPARATE process — which +// is exactly the harness/CLI split — and deleted cleanly, leaving the store +// file empty. +// +// That is why the shim below drives getKeychain() instead of a backend: the +// credential lands wherever the CLI will look for it, on every platform, +// without the harness having to know which backend won. nodeKeychainOpTimeout +// covers the remaining unknown — a future backend that blocks instead of +// throwing becomes a loud skip, not a hung CI job. +// +// WHY THAT IS SAFE HERE +// +// Node derives its service name from API_HOST (Token.getServiceName, +// src/lib/token.ts:119-129). The differential pins API_HOST to an httptest +// server on 127.0.0.1 with an ephemeral port, so every run gets a service name +// that no human ever typed and that cannot collide with a real credential. +// Every keychain operation in this file asserts that property FIRST +// (assertEphemeralParityService) — a positive match, not a denylist. A name +// like "vip-go-cli" or "vip-go-cli:http---localhost-4000" is refused outright. +// +// A previous incarnation of this pattern left 727 orphaned entries behind, so +// there are three nets. First, the whole test binary shares ONE credential +// (differential_test.go): the number a run creates is a constant, not a +// function of how many scenarios exist. Second, TestMain tears that credential +// down after m.Run() whether the suite passed, failed or panicked. Third, a +// pre-run and a post-run sweep collect anything a killed run stranded and +// anything the CLIs under test wrote for themselves (see keychain_test.go). +// +// NOTHING IN THIS FILE EVER PRINTS A SECRET. Tokens travel to the node shim on +// stdin (never argv, never the environment), the shim redacts the secret out of +// any error message it emits, and enumeration uses `security dump-keychain` +// WITHOUT -d so no password material is ever decrypted or displayed. + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/rechallenge" +) + +// nodeKeychainService is Node's SERVICE constant (src/lib/token.ts:14). +const nodeKeychainService = "vip-go-cli" + +// nodeProductionAPIHost is Node's PRODUCTION_API_HOST +// (src/lib/api/constants.ts:1). Node omits the host suffix for this endpoint. +const nodeProductionAPIHost = "https://api.wpvip.com" + +// nodeNonAlphanumeric mirrors Node's API_HOST.replace(/[^a-z0-9]/gi, '-') +// (src/lib/token.ts:123). The /i flag makes it case-insensitive, so uppercase +// letters survive — the class below is spelled out rather than folded. +var nodeNonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// NodeKeychainService reproduces Token.getServiceName (src/lib/token.ts:119-129) +// exactly: +// +// let service = SERVICE; // 'vip-go-cli' +// if ( PRODUCTION_API_HOST !== API_HOST ) { +// const sanitized = API_HOST.replace( /[^a-z0-9]/gi, '-' ); +// service = `${ SERVICE }:${ sanitized }`; +// } +// return `${ service }${ modifier }`; +// +// modifier is "" for the token entry and "-uuid" for the analytics anon-id +// entry that Token.uuid() creates (src/lib/token.ts:82). +// +// Note there is deliberately NO trailing-slash normalisation: Node does not do +// any, so neither does this. Go's own keychain.ServiceNameForHost DOES trim a +// trailing slash, which is a (harmless here, real elsewhere) divergence. +func NodeKeychainService(apiHost, modifier string) string { + service := nodeKeychainService + if apiHost != nodeProductionAPIHost { + service = nodeKeychainService + ":" + nodeNonAlphanumeric.ReplaceAllString(apiHost, "-") + } + return service + modifier +} + +// minEphemeralPort is the low-water mark of the ephemeral port range across the +// platforms this suite runs on: Linux starts at 32768, macOS and Windows at +// 49152. An httptest server (net.Listen on 127.0.0.1:0) always lands at or +// above this; a service name a human configured — a local Parker on :4000, the +// dead-loopback :1, the hostile-env :9 — never does. +const minEphemeralPort = 32768 + +// ephemeralParityServiceRe is the POSITIVE assertion that gates every keychain +// write and delete in this package. A service name must be: +// +// - one of the two CLI namespaces (vip-go-cli for Node, vip-next-cli for Go), +// - optionally the Go elevated-token namespace (":elevated"), +// - scoped to a host, and that host must be exactly http://127.0.0.1:<port> +// sanitised to http---127-0-0-1-<port> — "localhost" is NOT accepted, +// - optionally suffixed "-uuid" (Node's analytics anon-id entry). +// +// The port is then range-checked against minEphemeralPort. Both conditions must +// hold; there is no denylist anywhere and no way to opt a name past this. +var ephemeralParityServiceRe = regexp.MustCompile( + `^vip-(?:go|next)-cli(?::elevated)?:http---127-0-0-1-([0-9]{1,5})(?:-uuid)?$`) + +// IsEphemeralParityService reports whether name is a credential this harness is +// permitted to create and destroy. +// +// It refuses, among everything else, all of these real credentials: +// +// vip-go-cli (no host scope) +// vip-go-cli-uuid (no host scope) +// vip-next-cli-uuid (no host scope) +// vip-go-cli:http---127-0-0-1-4000-uuid (port 4000 < 32768) +// vip-go-cli:http---localhost-4000 (host is not 127.0.0.1) +// vip-go-cli:http---localhost-4000-uuid (host is not 127.0.0.1) +// vip-go-cli:http---127-0-0-1-9-uuid (port 9 < 32768) +// vip-next-cli:http---127-0-0-1-9-uuid (port 9 < 32768) +func IsEphemeralParityService(name string) bool { + m := ephemeralParityServiceRe.FindStringSubmatch(name) + if m == nil { + return false + } + port, err := strconv.Atoi(m[1]) + if err != nil { + return false + } + return port >= minEphemeralPort && port <= 65535 +} + +// assertEphemeralParityService is the guard every mutating call runs first. +func assertEphemeralParityService(op, name string) error { + if IsEphemeralParityService(name) { + return nil + } + return fmt.Errorf( + "parity keychain: refusing to %s %q — only credentials scoped to an ephemeral "+ + "loopback port (vip-{go,next}-cli[:elevated]:http---127-0-0-1-<port>[-uuid], "+ + "port >= %d) may be touched by the harness", + op, name, minEphemeralPort) +} + +// ErrKeychainUnsupported is returned by the /usr/bin/security-backed +// enumeration and deletion helpers on platforms that do not have it. +// +// It does NOT mean the differential cannot run there. Seeding and deleting go +// through Node's own keychain layer (see nodeKeychainScript) and work +// everywhere Node does; this error only marks the macOS-specific ORPHAN SWEEP +// as unavailable. Callers must therefore treat it as "nothing to sweep", never +// as a failure and never as a reason to skip a scenario. +// +// The asymmetry that leaves behind, stated plainly: on macOS a run killed +// mid-flight (SIGKILL, ^C) leaves a keychain item that the next run's pre-run +// sweep collects. On Linux the equivalent orphan is one JSON key inside +// ~/.config/configstore/vip-go-cli.json, and there is no sweep for it — the +// key is removed by the normal teardown, but not recovered if the process is +// killed before teardown. That is deliberate: enumerating configstore means +// reaching into a private field of the vendored Insecure backend, and the +// exposure it would buy back is one key on a CI runner that is destroyed at +// the end of the job. +var ErrKeychainUnsupported = errors.New( + "parity keychain: the orphan sweep is only implemented on macOS") + +const securityBin = "/usr/bin/security" + +// ParityKeychainServices lists every service name a differential run against +// apiHost can bring into existence — the set that must be cleaned up. +// +// Two are Node's (src/lib/token.ts): the token entry the harness seeds, and the +// "-uuid" analytics entry Token.uuid() creates on its own. trackEvent() calls +// Token.uuid() unconditionally (src/lib/tracker.ts:55), BEFORE the DO_NOT_TRACK +// check further down the stack, so that entry appears even with telemetry off. +// +// Two are Go's, taken from the production derivations so they cannot drift: +// the auth store's entry and the rechallenge elevated-token cache. +func ParityKeychainServices(apiHost string) []string { + return []string{ + NodeKeychainService(apiHost, ""), + NodeKeychainService(apiHost, "-uuid"), + keychain.ServiceNameForHost(apiHost), + rechallenge.ServiceNameForHost(apiHost), + } +} + +// nodeKeychainScript drives NODE'S OWN keychain layer — dist/lib/keychain.js, +// the compiled src/lib/keychain.ts — rather than reaching for a backend +// directly. +// +// That indirection is the whole point. getKeychain() tries Secure (keytar) and +// falls back to Insecure (a configstore JSON file) when Secure throws +// (src/lib/keychain.ts:12-23). Which one wins depends on the host: macOS gets +// Secure, a headless Linux CI runner with no usable secret service gets +// Insecure. Seeding through the same function the CLI reads through means the +// harness cannot write to a store the CLI will not consult — the seed lands +// wherever the read will look, by construction, on every platform. +// +// It also preserves the property the previous keytar-direct version had: an +// entry created by /usr/bin/security carries an ACL partition list of +// "apple-tool:" only, and a later read from node blocks on a GUI authorisation +// prompt. Writing through node gives the item the partition list node can read. +// +// The secret arrives on stdin — not argv (visible in ps) and not the +// environment. Every error message has the secret spliced out before it is +// printed, so a backend failure can never leak the token. +const nodeKeychainScript = ` +const { getKeychain } = require(process.env.VIP_PARITY_KEYCHAIN_DIST + '/lib/keychain.js'); +const service = process.env.VIP_PARITY_KEYCHAIN_SERVICE; +const op = process.env.VIP_PARITY_KEYCHAIN_OP; +const secret = op === 'set' ? require('node:fs').readFileSync(0, 'utf8') : ''; +const redact = err => { + const text = String((err && err.stack) || (err && err.message) || err); + return secret ? text.split(secret).join('<redacted>') : text; +}; +(async () => { + const keychain = await getKeychain(); + if (op === 'delete') { + await keychain.deletePassword(service); + process.stdout.write('DELETED:' + keychain.constructor.name); + return; + } + await keychain.setPassword(service, secret); + const readBack = await keychain.getPassword(service); + process.stdout.write((readBack === secret ? 'VERIFIED:' : 'MISMATCH:') + keychain.constructor.name); +})().catch(err => { process.stderr.write(redact(err)); process.exit(1); }); +` + +// nodeKeychainOpTimeout bounds every call into Node's keychain layer. +// +// On macOS the whole round trip is ~25ms and on a keytar-less host the +// Insecure fallback is ~15ms (both measured). The budget is enormous next to +// that on purpose: it exists solely so a credential backend that BLOCKS rather +// than throwing — libsecret waiting on a D-Bus session that will never +// autolaunch is the plausible Linux case — turns into a loud skip instead of a +// CI job that hangs until the runner's own timeout kills it. +const nodeKeychainOpTimeout = 60 * time.Second + +// ErrKeychainSeedMismatch means the write reported success but the value read +// back was not the value written. That is a harness bug (a wrong service name, +// a wrong account), not a hostile environment, so callers should FAIL on it +// rather than skip. +var ErrKeychainSeedMismatch = errors.New( + "parity keychain: seeded entry did not read back as written") + +// SeedNodeKeychainToken writes token into the credential the Node CLI reads for +// apiHost, through Node's own keychain layer. +// +// The caller MUST register cleanup for ParityKeychainServices(apiHost) BEFORE +// calling this, so a partial write is still collected. +// +// A non-nil error other than ErrKeychainSeedMismatch means the credential store +// could not be driven at all (no node, no dist/, no usable backend, a locked +// keychain, a backend that timed out); the caller turns that into a loud skip. +func SeedNodeKeychainToken(nodeBin, apiHost, token string) error { + service := NodeKeychainService(apiHost, "") + if err := assertEphemeralParityService("write", service); err != nil { + return err + } + if token == "" { + return errors.New("parity keychain: refusing to seed an empty token") + } + out, err := runNodeKeychainOp(nodeBin, service, "set", token) + if err != nil { + return err + } + if !strings.HasPrefix(out, "VERIFIED:") { + return fmt.Errorf("%w: service %q (backend reported %q)", ErrKeychainSeedMismatch, service, out) + } + return nil +} + +// DeleteNodeKeychainService removes name through Node's own keychain layer. +// +// This is the platform-portable half of cleanup: DeleteParityKeychainService +// below shells out to /usr/bin/security and therefore only exists on macOS, +// but on a Linux runner the credential lives in a configstore JSON file that +// only Node knows the path of. Deleting through getKeychain() removes exactly +// the key that was written, whichever backend holds it. +func DeleteNodeKeychainService(nodeBin, name string) error { + if err := assertEphemeralParityService("delete", name); err != nil { + return err + } + _, err := runNodeKeychainOp(nodeBin, name, "delete", "") + return err +} + +func runNodeKeychainOp(nodeBin, service, op, secret string) (string, error) { + dist, err := nodeDistDir(nodeBin) + if err != nil { + return "", err + } + root, ok := nodeModulesRoot(nodeBin) + if !ok { + return "", fmt.Errorf("parity keychain: no node_modules above %s; run `npm ci`", nodeBin) + } + + ctx, cancel := context.WithTimeout(context.Background(), nodeKeychainOpTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "node", "-e", nodeKeychainScript) + cmd.Dir = root + // The shim gets the scrubbed fixture environment plus the operands, and + // explicitly NO credential: it receives the secret on stdin instead. + cmd.Env = FixtureEnv(map[string]string{ + "VIP_PARITY_KEYCHAIN_DIST": dist, + "VIP_PARITY_KEYCHAIN_SERVICE": service, + "VIP_PARITY_KEYCHAIN_OP": op, + "VIP_TOKEN_OVERRIDE": "", + }) + if secret != "" { + cmd.Stdin = strings.NewReader(secret) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + if ctx.Err() != nil { + return "", fmt.Errorf("parity keychain: %s %q timed out after %s — the credential "+ + "backend blocked instead of failing", op, service, nodeKeychainOpTimeout) + } + if runErr != nil { + return "", fmt.Errorf("parity keychain: node could not %s %q (%v): %s", + op, service, runErr, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +// nodeDistDir maps the CLI entrypoint to the compiled tree that holds +// lib/keychain.js (dist/bin/vip.js -> dist). +func nodeDistDir(binPath string) (string, error) { + dist := filepath.Dir(filepath.Dir(binPath)) + if _, err := os.Stat(filepath.Join(dist, "lib", "keychain.js")); err != nil { + return "", fmt.Errorf("parity keychain: %s has no lib/keychain.js "+ + "(derived from NODE_VIP_BIN=%s); run `npm ci`", dist, binPath) + } + return dist, nil +} + +// nodeModulesRoot walks up from the Node entrypoint to the directory that owns +// node_modules (dist/bin/vip.js -> dist/bin -> dist -> <root>). +func nodeModulesRoot(binPath string) (string, bool) { + dir := filepath.Dir(binPath) + for { + if info, err := os.Stat(filepath.Join(dir, "node_modules")); err == nil && info.IsDir() { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} + +// DeleteParityKeychainService removes every credential stored under name. +// +// It deletes by SERVICE rather than by (service, account) on purpose: Go's auth +// store writes a second item under the same service with the account +// "<service>:legacy-fallback-disabled" (internal/auth/store.go:128), and a +// (service, account) delete would leave it behind. The service name itself is +// what the guard validates, so removing everything under it is bounded. +// +// A name that is already absent is not an error — cleanup must be idempotent. +func DeleteParityKeychainService(name string) error { + if err := assertEphemeralParityService("delete", name); err != nil { + return err + } + if runtime.GOOS != "darwin" { + return ErrKeychainUnsupported + } + for { + // No -g/-w: attributes only, so nothing is ever decrypted or printed. + out, err := exec.Command(securityBin, "delete-generic-password", "-s", name).CombinedOutput() + if err != nil { + if strings.Contains(string(out), "could not be found") { + return nil + } + return fmt.Errorf("parity keychain: delete %q: %v: %s", + name, err, strings.TrimSpace(string(out))) + } + } +} + +// CleanupParityKeychainServices deletes each name, guard first. It keeps going +// after a failure so one bad name cannot strand the rest, and returns the +// joined error. +func CleanupParityKeychainServices(names []string) error { + var errs []error + for _, name := range names { + if err := DeleteParityKeychainService(name); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// CleanupParityCredentials removes every credential a differential run against +// apiHost can have created, on any platform. +// +// It runs BOTH deletion paths because they cover different ground: +// +// - Node's own keychain layer knows where Node put the entry, which on a +// host without a usable secret service is a configstore JSON file that +// /usr/bin/security cannot see at all; +// - /usr/bin/security deletes by SERVICE, which collects the SECOND item Go's +// auth store writes under the same service with the account +// "<service>:legacy-fallback-disabled" (internal/auth/store.go) — a +// (service, account) delete through Node would leave that behind. +// +// ErrKeychainUnsupported from the macOS-only path is not an error: on Linux +// there is simply nothing for it to do. Everything else is reported, and the +// function keeps going so one failure cannot strand the rest. +func CleanupParityCredentials(nodeBin, apiHost string) error { + var errs []error + for _, name := range ParityKeychainServices(apiHost) { + if nodeBin != "" { + if err := DeleteNodeKeychainService(nodeBin, name); err != nil { + errs = append(errs, err) + } + } + if err := DeleteParityKeychainService(name); err != nil && + !errors.Is(err, ErrKeychainUnsupported) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// svceLine matches the service attribute in `security dump-keychain` output: +// +// "svce"<blob>="vip-go-cli:http---127-0-0-1-63145-uuid" +var svceLine = regexp.MustCompile(`^\s*"svce"<blob>="(.*)"\s*$`) + +// listKeychainServices enumerates the service names in the user's keychain +// search list. +// +// `security dump-keychain` WITHOUT -d dumps attributes only. It never decrypts +// a password and never prompts. Values that are not printable ASCII come back +// as 0x… hex instead of a quoted string and are simply not matched — every +// service name this harness cares about is plain ASCII. +func listKeychainServices() ([]string, error) { + if runtime.GOOS != "darwin" { + return nil, ErrKeychainUnsupported + } + // dump-keychain exits non-zero when any keychain in the search list is + // unreadable while still dumping the rest, so a non-empty stdout wins over + // the exit status. + out, err := exec.Command(securityBin, "dump-keychain").Output() + if len(out) == 0 && err != nil { + return nil, fmt.Errorf("parity keychain: dump-keychain: %w", err) + } + + var services []string + seen := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + m := svceLine.FindStringSubmatch(line) + if m == nil || seen[m[1]] { + continue + } + seen[m[1]] = true + services = append(services, m[1]) + } + return services, nil +} + +// SweepEphemeralParityKeychain deletes every credential whose service name +// passes IsEphemeralParityService, and returns the names it removed. +// +// This is the orphan collector. A test binary that is killed (SIGKILL, a +// panicking child, ^C) never runs t.Cleanup, so entries survive; running this +// at the START of a run collects them, and running it at the END collects +// anything the CLIs under test wrote for themselves — the Go binary creates +// vip-next-cli:<host> and vip-next-cli:elevated:<host> entries during the login +// and rechallenge scenarios, which no per-test cleanup knows about. +// +// It cannot touch a real credential: a name only qualifies if it is scoped to +// 127.0.0.1 on a port in the ephemeral range, which is a shape only a +// throwaway httptest server produces. +func SweepEphemeralParityKeychain() ([]string, error) { + services, err := listKeychainServices() + if err != nil { + return nil, err + } + var removed []string + var errs []error + for _, name := range services { + if !IsEphemeralParityService(name) { + continue + } + if err := DeleteParityKeychainService(name); err != nil { + errs = append(errs, err) + continue + } + removed = append(removed, name) + } + return removed, errors.Join(errs...) +} diff --git a/internal/parity/keychain_test.go b/internal/parity/keychain_test.go new file mode 100644 index 000000000..d91b360f8 --- /dev/null +++ b/internal/parity/keychain_test.go @@ -0,0 +1,201 @@ +//go:build parity + +package parity + +import ( + "fmt" + "os" + "testing" +) + +// realCredentialsOnDevMachines is the set of service names that belong to a +// human — a developer's actual login, a long-lived local Parker, the +// dead-loopback and hostile-proxy hosts the harness pins. Every one of them +// MUST be refused by the guard. This list is a TEST FIXTURE, not the guard's +// implementation: the guard is a positive match on the ephemeral shape and has +// no denylist, so adding a name here can only ever prove the property, never +// create it. +var realCredentialsOnDevMachines = []string{ + "vip-go-cli", + "vip-go-cli-uuid", + "vip-next-cli", + "vip-next-cli-uuid", + "vip-go-cli:http---127-0-0-1-4000-uuid", + "vip-go-cli:http---localhost-4000", + "vip-go-cli:http---localhost-4000-uuid", + "vip-go-cli:http---127-0-0-1-9-uuid", + "vip-next-cli:http---127-0-0-1-9-uuid", + "vip-go-cli:http---127-0-0-1-1", + "vip-go-cli:https---api-wpvip-com", + "vip-next-cli:elevated", + "vip-next-cli:elevated:https---api-wpvip-com", +} + +// TestKeychainGuardRefusesRealCredentials is the safety property the whole +// keychain mechanism rests on. If this test ever goes red, the harness can +// delete a developer's live login. +func TestKeychainGuardRefusesRealCredentials(t *testing.T) { + for _, name := range realCredentialsOnDevMachines { + if IsEphemeralParityService(name) { + t.Errorf("guard ACCEPTED a real credential %q — the harness could destroy it", name) + } + if err := assertEphemeralParityService("write", name); err == nil { + t.Errorf("assertEphemeralParityService(write, %q) = nil, want refusal", name) + } + if err := assertEphemeralParityService("delete", name); err == nil { + t.Errorf("assertEphemeralParityService(delete, %q) = nil, want refusal", name) + } + } +} + +// TestKeychainMutatorsRefuseNonEphemeralNames proves the refusal is enforced at +// the mutating call sites, not merely available as a predicate. Neither call +// may reach /usr/bin/security. +func TestKeychainMutatorsRefuseNonEphemeralNames(t *testing.T) { + const real = "vip-go-cli" + + if err := DeleteParityKeychainService(real); err == nil { + t.Fatalf("DeleteParityKeychainService(%q) = nil, want refusal", real) + } + // SeedNodeKeychainToken derives the service from the host, so feed it a host + // that yields the production name. + if err := SeedNodeKeychainToken("/nonexistent/vip.js", nodeProductionAPIHost, "a.b.c"); err == nil { + t.Fatal("SeedNodeKeychainToken against the production API host = nil, want refusal") + } + if err := CleanupParityKeychainServices([]string{real}); err == nil { + t.Fatalf("CleanupParityKeychainServices([%q]) = nil, want refusal", real) + } +} + +func TestKeychainGuardAcceptsEphemeralScopedNames(t *testing.T) { + accepted := []string{ + "vip-go-cli:http---127-0-0-1-63145", + "vip-go-cli:http---127-0-0-1-63145-uuid", + "vip-next-cli:http---127-0-0-1-61991", + "vip-next-cli:elevated:http---127-0-0-1-62010", + "vip-go-cli:http---127-0-0-1-32768", + "vip-next-cli:http---127-0-0-1-65535", + } + for _, name := range accepted { + if !IsEphemeralParityService(name) { + t.Errorf("guard refused %q, which only an httptest server can produce", name) + } + } + + // Just below the ephemeral floor is refused: a port a human picked. + if IsEphemeralParityService("vip-go-cli:http---127-0-0-1-32767") { + t.Error("guard accepted port 32767, below the ephemeral floor") + } + // A port that cannot exist is refused rather than wrapped. + if IsEphemeralParityService("vip-go-cli:http---127-0-0-1-65536") { + t.Error("guard accepted port 65536") + } + // Suffixes and prefixes must not sneak past. + for _, name := range []string{ + "xvip-go-cli:http---127-0-0-1-63145", + "vip-go-cli:http---127-0-0-1-63145-uuid-extra", + "vip-go-cli:http---127-0-0-1-63145:legacy-fallback-disabled", + "vip-other-cli:http---127-0-0-1-63145", + } { + if IsEphemeralParityService(name) { + t.Errorf("guard accepted %q", name) + } + } +} + +// TestNodeKeychainServiceMatchesNodeDerivation pins the port of +// Token.getServiceName (src/lib/token.ts:119-129). If Node's derivation ever +// changes, the differential would silently authenticate nothing; this catches +// it at the unit level instead. +func TestNodeKeychainServiceMatchesNodeDerivation(t *testing.T) { + cases := []struct { + host, modifier, want string + }{ + // PRODUCTION_API_HOST !== API_HOST is false -> bare SERVICE. + {nodeProductionAPIHost, "", "vip-go-cli"}, + {nodeProductionAPIHost, "-uuid", "vip-go-cli-uuid"}, + // Everything else is suffixed with the sanitised host. + {"http://127.0.0.1:63145", "", "vip-go-cli:http---127-0-0-1-63145"}, + {"http://127.0.0.1:63145", "-uuid", "vip-go-cli:http---127-0-0-1-63145-uuid"}, + {"http://localhost:4000", "", "vip-go-cli:http---localhost-4000"}, + // The /i flag on Node's regex means uppercase is alphanumeric too. + {"https://API.WPVIP.com", "", "vip-go-cli:https---API-WPVIP-com"}, + } + for _, tc := range cases { + if got := NodeKeychainService(tc.host, tc.modifier); got != tc.want { + t.Errorf("NodeKeychainService(%q, %q) = %q, want %q", tc.host, tc.modifier, got, tc.want) + } + } +} + +// TestParityKeychainServicesCoversBothCLIs asserts the cleanup set names every +// entry a differential run can create, and that all of them clear the guard — +// an entry the guard would refuse is an entry cleanup cannot remove. +func TestParityKeychainServicesCoversBothCLIs(t *testing.T) { + const host = "http://127.0.0.1:54321" + got := ParityKeychainServices(host) + + want := map[string]bool{ + "vip-go-cli:http---127-0-0-1-54321": false, // Node token (seeded here) + "vip-go-cli:http---127-0-0-1-54321-uuid": false, // Node analytics anon-id + "vip-next-cli:http---127-0-0-1-54321": false, // Go auth store + "vip-next-cli:elevated:http---127-0-0-1-54321": false, // Go rechallenge cache + } + for _, name := range got { + if _, ok := want[name]; !ok { + t.Errorf("unexpected service %q in the cleanup set", name) + continue + } + want[name] = true + if !IsEphemeralParityService(name) { + t.Errorf("cleanup set contains %q, which the guard refuses to delete", name) + } + } + for name, seen := range want { + if !seen { + t.Errorf("cleanup set is missing %q", name) + } + } +} + +// TestMain brackets the whole parity suite with credential cleanup. +// +// PRE-RUN SWEEP: collect entries left by a run that was killed before any +// cleanup could fire. POST-RUN: tear down the shared differential rig (which +// deletes the one credential the run seeded, through Node's own keychain layer +// so it works on any backend), then sweep again for entries the CLIs under +// test wrote for THEMSELVES — the Go binary creates its own vip-next-cli:<host> +// and vip-next-cli:elevated:<host> credentials during the login and rechallenge +// scenarios, which no named cleanup knows about. +// +// Order matters: the rig teardown must run before the sweep, so that anything +// it fails to remove is still caught by the sweep rather than surviving to the +// next run. +// +// Together this is what makes the `vip*` service count identical before and +// after `make test-parity-unit`, and keeps it identical as scenarios are added +// — the run seeds ONE credential regardless of how many differentials there +// are (see differential_test.go). +// +// Sweep failures are reported but never fail the suite: this is hygiene, not an +// assertion about the code under test. +func TestMain(m *testing.M) { + reportSweep("pre-run (orphans from an interrupted run)") + code := m.Run() + teardownDifferentialRig() + reportSweep("post-run (entries the CLIs under test created)") + os.Exit(code) +} + +func reportSweep(phase string) { + removed, err := SweepEphemeralParityKeychain() + if err != nil { + // Not fatal: on Linux/CI there is no keychain to sweep, and a locked + // keychain is the developer's business, not this suite's. + fmt.Fprintf(os.Stderr, "parity keychain sweep %s: %v\n", phase, err) + } + // Service names are not secrets; the values under them are never read. + for _, name := range removed { + fmt.Fprintf(os.Stderr, "parity keychain sweep %s: removed %s\n", phase, name) + } +} diff --git a/internal/parity/m5_differential_test.go b/internal/parity/m5_differential_test.go new file mode 100644 index 000000000..85e316a78 --- /dev/null +++ b/internal/parity/m5_differential_test.go @@ -0,0 +1,185 @@ +//go:build parity + +package parity + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// m5DifferentialScenarios is the vetted allowlist of M5 scenarios that run as +// REAL Node-vs-Go differentials: both CLIs are executed against the same +// httptest mock and stdout+stderr+exit-code are diffed. +// +// TestM5Scenarios (m5_scenarios_test.go) runs the same YAML against vip-next +// ONLY, and asserts nothing but the exit code. That is a Go-behaviour test +// wearing a parity build tag — it is how the `--format=keyValue` shape +// divergence survived a review. Membership here is what makes a scenario +// actually compare the two implementations. +// +// A scenario belongs here only if all three hold: +// +// 1. read-only and side-effect free (no mutation reaches the mock), +// 2. the mock already serves every operation BOTH CLIs issue, +// 3. the Node CLI needs no credential beyond the one the rig seeds. +// +// Everything else must be listed in m5DifferentialExclusions with a reason. +// TestEveryM5ScenarioIsClassified fails when a scenario appears in neither, so +// a new YAML cannot quietly opt out of the differential. +var m5DifferentialScenarios = []string{ + // `app list` — the review's named high-signal target. Covers the default + // table renderer plus the three --format branches and the empty case. + "app-list-baseline", + "app-list-json", + "app-list-csv", + "app-list-empty", + // Two --format branches the review named that had no scenario at all. + "app-list-ids", + "app-list-unknown-format", + + // `config envvar list` — the format matrix the keyValue divergence hid in. + "envvar-list-baseline", + "envvar-list-json", + "envvar-list-keyvalue", + "envvar-list-ids", + "envvar-list-empty", + + // `config envvar get-all` — second keyValue surface (key + value columns). + "envvar-getall-baseline", + "envvar-getall-keyvalue", + "envvar-getall-empty", + + // `config envvar get` — single-value read path. envvar-get-named-help is + // the cutover-2.13 repro: the positional is a VARIABLE named "help", not a + // help request, so it is an ordinary read and belongs here. + "envvar-get-baseline", + "envvar-get-not-found", + "envvar-get-lowercase-input", + "envvar-get-named-help", + + // `logs` — the review flagged tab mangling and a `__typename` column shift. + "logs-baseline", + "logs-format-json", + "logs-empty", + "logs-batch", + "logs-limit-100", + + // `slowlogs` — same renderer family, independent query. + "slowlogs-baseline", + "slowlogs-csv", + "slowlogs-empty", + "slowlogs-limit-50", + + // `app get` — read-only app lookup. app-get-custom-deploy is named for the + // environment's deploymentStrategy in the recording, not for a deploy + // token; it needs no credential beyond the seeded one. + "app-get-baseline", + "app-get-json", + "app-get-not-found", + "app-get-custom-deploy", +} + +// m5DifferentialExclusions records M5 scenarios deliberately NOT run as +// differentials, each with the reason. Keep the reason specific: "flaky" is not +// a reason, "Node authenticates with a deploy token the fixture env does not +// carry" is. Verify the reason against the scenario's YAML and the Node source +// before adding an entry — a plausible-sounding reason that turns out to be +// wrong is how coverage silently disappears. +// +// Currently empty: every M5 scenario runs as a real differential. +var m5DifferentialExclusions = map[string]string{} + +func TestM5DifferentialParity(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("TestM5DifferentialParity — every M5 Node-vs-Go differential scenario", skip)) + } + + for _, name := range m5DifferentialScenarios { + // No t.Parallel: subtests swap the shared server's handler. + t.Run(name, func(t *testing.T) { + path := "../../testdata/parity/" + name + ".yaml" + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + + rig.serve(t, m5Mux(t, scenario.Recording)) + scenario.Env = rig.scenarioEnv(scenario) + + d, err := CompareBinaries(scenario, rig.nodeBin, rig.goBin) + if err != nil { + t.Fatalf("CompareBinaries(%s): %v", name, err) + } + if d.Equal { + if scenario.ExpectedDrift != nil { + t.Errorf("%s carries expected_drift (%s) but Node and Go now agree. "+ + "Delete the annotation.", name, scenario.ExpectedDrift.Reason) + } + return + } + + // A divergence is a FINDING. It may only be downgraded to a note by + // an explicit, reasoned and fingerprinted expected_drift in the scenario YAML — which + // is a decision about product behaviour, recorded next to the + // scenario, not something the harness may infer. + report := "Node vs Go diverge (argv: %v):\n %s\n %s\n %s" + if scenario.ExpectedDrift != nil { + // Announced on stderr as well as via t.Log so it survives into + // any run that keeps output. `go test` discards a PASSING + // package's output entirely without -v, which is why + // `make test-parity-unit` also prints the blessed list up front + // (blessed-drift-status), reading the same YAML annotations. + fmt.Fprintf(os.Stderr, "parity: BLESSED DRIFT %s — %s\n", + name, strings.Join(strings.Fields(scenario.ExpectedDrift.Reason), " ")) + t.Logf("BLESSED DRIFT — "+scenario.ExpectedDrift.Reason+"\n"+report, + scenario.Argv, d.ExitCodeDelta, d.StdoutDelta, d.StderrDelta) + return + } + t.Errorf(report, scenario.Argv, d.ExitCodeDelta, d.StdoutDelta, d.StderrDelta) + }) + } +} + +// TestEveryM5ScenarioIsClassified is the anti-drift guard. Every M5 YAML must +// be either a differential or an explicitly-reasoned exclusion. Without it, +// adding a scenario silently produces another Go-vs-mock test that looks like +// parity coverage and is not. +func TestEveryM5ScenarioIsClassified(t *testing.T) { + entries, err := filepath.Glob("../../testdata/parity/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + inDifferential := make(map[string]bool, len(m5DifferentialScenarios)) + for _, name := range m5DifferentialScenarios { + inDifferential[name] = true + } + + var seen int + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if !isM5Scenario(base) { + continue + } + seen++ + if inDifferential[base] { + continue + } + if reason, ok := m5DifferentialExclusions[base]; ok { + if strings.TrimSpace(reason) == "" { + t.Errorf("%s is excluded from the differential with an empty reason", base) + } + continue + } + t.Errorf("M5 scenario %s runs against the mock only. Add it to "+ + "m5DifferentialScenarios, or to m5DifferentialExclusions with a reason "+ + "saying why Node cannot run it.", base) + } + if seen == 0 { + t.Fatal("no M5 scenarios found — testdata may have moved") + } +} diff --git a/internal/parity/m5_scenarios_test.go b/internal/parity/m5_scenarios_test.go new file mode 100644 index 000000000..9335582e8 --- /dev/null +++ b/internal/parity/m5_scenarios_test.go @@ -0,0 +1,225 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// m5Mux returns an HTTP handler that dispatches GraphQL requests to recording +// files by matching the "operationName" field in the request body. +// +// It must answer BOTH CLIs, because these recordings back real Node-vs-Go +// differentials (TestM5DifferentialParity), not just vip-next. The two +// implementations name the same operations differently — Node's queries are +// written inline in src/, Go's are genqlient operations in +// internal/gql/operations/ — so each fixture is reachable under either name. +// +// Go operation → file mapping: +// +// AppList -> apps.json +// AppGetByName, AppGetByID -> app.json +// ResolveAppByName, ResolveAppByID -> resolve-app.json +// GetEnvironmentVariables -> envvars.json +// GetEnvironmentVariablesWithValues -> envvars.json +// GetAppLogs -> logs.json +// GetAppSlowlogs -> slowlogs.json +// +// Node operation → file mapping (names verified against trunk 4.1.0): +// +// Apps (src/bin/vip-app-list.js:37) -> apps.json +// App (src/lib/api/app.ts:46,69) -> resolve-app.json, else app.json +// GetEnvironmentVariables (src/lib/envvar/api-list.ts:11) +// GetEnvironmentVariablesWithValues (src/lib/envvar/api-get-all.ts:11) +// GetAppLogs (src/lib/app-logs/app-logs.ts:10 AND +// src/lib/app-slowlogs/app-slowlogs.ts:12) +// +// Node issues ONE `App` operation where Go issues two differently-named ones: +// src/lib/api/app.ts serves both the `@app.env` context resolution and +// `vip app <name>`, varying only the requested field set. The recordings keep +// that split (resolve-app.json vs app.json), so `App` prefers resolve-app.json +// and falls back to app.json — which is exactly the file the correspondingly +// named Go operation would have been served. +// +// If a recording file is absent, serves {"data":null} (non-fatal). +func m5Mux(t *testing.T, recordingDir string) http.Handler { + t.Helper() + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + maybeRead := func(name string) []byte { + b, err := os.ReadFile(base + name) + if err != nil { + return nil + } + return b + } + + appsBody := maybeRead("apps.json") + appBody := maybeRead("app.json") + resolveAppBody := maybeRead("resolve-app.json") + envvarsBody := maybeRead("envvars.json") + logsBody := maybeRead("logs.json") + slowlogsBody := maybeRead("slowlogs.json") + + nullBody := []byte(`{"data":null}`) + + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + + switch { + // Node's `Apps` must be matched before its `App`: the trailing quote in + // the literal already prevents `"App"` from matching `"Apps"`, but + // keeping the wider name first documents the intent. + case strings.Contains(s, `"operationName":"AppList"`), + strings.Contains(s, `"operationName":"Apps"`): + serve(w, appsBody) + case strings.Contains(s, `"operationName":"AppGetByName"`), + strings.Contains(s, `"operationName":"AppGetByID"`): + serve(w, appBody) + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`): + serve(w, resolveAppBody) + // Node's single `App` operation covers both of the two cases above. + case strings.Contains(s, `"operationName":"App"`): + if resolveAppBody != nil { + serve(w, resolveAppBody) + } else { + serve(w, appBody) + } + // Order matters: WithValues is checked first because its op-name + // contains "GetEnvironmentVariables" as a substring. Swapping + // these cases would silently misroute the values variant. + case strings.Contains(s, `"operationName":"GetEnvironmentVariablesWithValues"`): + serve(w, envvarsBody) + case strings.Contains(s, `"operationName":"GetEnvironmentVariables"`): + serve(w, envvarsBody) + case strings.Contains(s, `"operationName":"GetAppSlowlogs"`): + serve(w, slowlogsBody) + // Node names its slowlogs query `GetAppLogs` too + // (src/lib/app-slowlogs/app-slowlogs.ts:12 and + // src/lib/app-logs/app-logs.ts:10 declare the same operation name), so + // the operation name alone cannot route Node's two log queries. The + // selection set is what distinguishes them: slowlogs selects + // `slowlogs(`, runtime logs select `logs(`. + case strings.Contains(s, `"operationName":"GetAppLogs"`): + if strings.Contains(s, "slowlogs(") { + serve(w, slowlogsBody) + } else { + serve(w, logsBody) + } + default: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(nullBody) + } + }) +} + +// m5Prefixes are the YAML name prefixes that identify M5 backlog scenarios. +var m5Prefixes = []string{ + "app-list-", + "app-get-", + "envvar-list-", + "envvar-get-", + "envvar-getall-", + "logs-", + "slowlogs-", +} + +func isM5Scenario(name string) bool { + for _, p := range m5Prefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// TestM5Scenarios discovers every YAML under testdata/parity/ whose filename +// matches an M5 prefix, starts a mock GraphQL server backed by the scenario's +// recording directory, and asserts the Go binary exits with the expected code. +func TestM5Scenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + var m5 []string + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if isM5Scenario(base) { + m5 = append(m5, path) + } + } + if len(m5) == 0 { + t.Fatal("no M5 scenarios found — testdata may have moved") + } + + // Build the binary once for all subtests. + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range m5 { + scenarioName := strings.TrimSuffix(filepath.Base(path), ".yaml") + + t.Run(scenarioName, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + + // A scenario carrying expected_drift asserts NODE's behaviour, which + // vip-next deliberately does not reproduce, so the exit-code check + // here would be wrong. Skipping loses nothing: every M5 scenario now + // also runs as a real Node-vs-Go differential + // (TestM5DifferentialParity), which compares stdout, stderr AND the + // exit code and announces the blessed divergence on stderr — that is + // strictly stronger than this exit-code-only assertion. + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); asserted by TestM5DifferentialParity instead of %s", + scenario.ExpectedDrift.Reason, scenarioName) + return + } + + mux := m5Mux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Errorf("Run(%s): %v", scenarioName, err) + return + } + + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("%s: exit code = %d, want %d\n stderr: %s\n stdout: %s", + scenarioName, res.ExitCode, scenario.Expect.ExitCode, + res.Stderr, res.Stdout) + } + }) + } +} diff --git a/internal/parity/nodebin.go b/internal/parity/nodebin.go new file mode 100644 index 000000000..95d68fc14 --- /dev/null +++ b/internal/parity/nodebin.go @@ -0,0 +1,120 @@ +//go:build parity + +package parity + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// NodeVipBinProbe abstracts the two host lookups needed to decide whether the +// Node CLI can actually be executed, so the decision is unit-testable without +// mutating PATH or the working tree. +type NodeVipBinProbe struct { + Stat func(string) (os.FileInfo, error) + LookPath func(string) (string, error) +} + +// DefaultNodeVipBinProbe queries the real filesystem and PATH. +func DefaultNodeVipBinProbe() NodeVipBinProbe { + return NodeVipBinProbe{Stat: os.Stat, LookPath: exec.LookPath} +} + +// NodeVipBinStatus is the verdict on a candidate Node CLI entrypoint. +// Reason is empty when Ready; otherwise it names precisely what is missing +// and the command that fixes it. +type NodeVipBinStatus struct { + Path string + Ready bool + Reason string +} + +// ResolveNodeVipBin decides whether the Node CLI at path can be run as the +// reference implementation in a differential scenario. +// +// It deliberately reports a REASON rather than a bare boolean: a developer who +// has not run `npm ci` must not be hard-failed, but neither may the suite +// silently report a pass while the only Node-vs-Go comparison in the repo +// quietly does nothing. +func ResolveNodeVipBin(path string, probe NodeVipBinProbe) NodeVipBinStatus { + if probe.Stat == nil { + probe.Stat = os.Stat + } + if probe.LookPath == nil { + probe.LookPath = exec.LookPath + } + + path = strings.TrimSpace(path) + if path == "" { + return NodeVipBinStatus{Reason: "NODE_VIP_BIN is not set. " + + "Run the suite through `make test-parity-unit`, which points it at ./dist/bin/vip.js."} + } + + info, err := probe.Stat(path) + if err != nil { + return NodeVipBinStatus{Path: path, Reason: fmt.Sprintf( + "the Node CLI is not built: NODE_VIP_BIN=%s does not exist. "+ + "Build it with `npm ci && npm run build` from the repo root.", path)} + } + if info.IsDir() { + return NodeVipBinStatus{Path: path, Reason: fmt.Sprintf( + "NODE_VIP_BIN=%s is not a file. It must point at the CLI entrypoint "+ + "(dist/bin/vip.js), not at a directory.", path)} + } + + if _, err := probe.LookPath("node"); err != nil { + return NodeVipBinStatus{Path: path, Reason: fmt.Sprintf( + "the `node` interpreter was not found on PATH, so NODE_VIP_BIN=%s cannot be executed. "+ + "Install Node 20+ (see package.json engines).", path)} + } + + if !hasNodeModules(path, probe.Stat) { + return NodeVipBinStatus{Path: path, Reason: fmt.Sprintf( + "node_modules is missing above NODE_VIP_BIN=%s, so the Node CLI cannot load its "+ + "runtime dependencies. Run `npm ci` from the repo root.", path)} + } + + return NodeVipBinStatus{Path: path, Ready: true} +} + +// hasNodeModules walks up from the entrypoint looking for the installed +// dependency tree (dist/bin/vip.js -> dist/bin -> dist -> <root>/node_modules). +func hasNodeModules(binPath string, stat func(string) (os.FileInfo, error)) bool { + dir := filepath.Dir(binPath) + for { + if info, err := stat(filepath.Join(dir, "node_modules")); err == nil && info.IsDir() { + return true + } + parent := filepath.Dir(dir) + if parent == dir { + return false + } + dir = parent + } +} + +// LoudSkip renders a skip banner and returns the one-line reason for t.Skip. +// +// `go test` buffers a passing package's output, so neither this banner nor the +// t.Skip body reaches the terminal without -v. The `test-parity-unit` Make +// target therefore prints its OWN banner before running the suite (it performs +// the same three checks in shell). This function is what makes the reason +// visible under `go test -v` and in CI logs that keep verbose output. +// +// Silence is the failure mode this slice exists to remove: a skipped +// differential test must never look like a passing one. +func LoudSkip(headline, reason string) string { + banner := strings.Join([]string{ + "", + "================================================================================", + " SKIPPED: " + headline, + " " + reason, + "================================================================================", + "", + }, "\n") + fmt.Fprint(os.Stderr, banner) + return headline + ": " + reason +} diff --git a/internal/parity/nodebin_test.go b/internal/parity/nodebin_test.go new file mode 100644 index 000000000..43b4f0973 --- /dev/null +++ b/internal/parity/nodebin_test.go @@ -0,0 +1,148 @@ +//go:build parity + +package parity + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// nodeProbeFor builds a probe whose PATH lookup succeeds for "node" only when +// nodeOnPath is true. The filesystem is the real one, rooted at dir. +func nodeProbeFor(nodeOnPath bool) NodeVipBinProbe { + return NodeVipBinProbe{ + Stat: os.Stat, + LookPath: func(name string) (string, error) { + if nodeOnPath && name == "node" { + return "/usr/bin/node", nil + } + return "", errors.New("not found") + }, + } +} + +// nodeCLILayout materialises a fake built Node CLI: <root>/dist/bin/vip.js +// plus, optionally, <root>/node_modules. +func nodeCLILayout(t *testing.T, withBin, withModules bool) string { + t.Helper() + root := t.TempDir() + if withBin { + binDir := filepath.Join(root, "dist", "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(binDir, "vip.js"), []byte("#!/usr/bin/env node\n"), 0o755); err != nil { // #nosec G306 + t.Fatalf("write vip.js: %v", err) + } + } + if withModules { + if err := os.MkdirAll(filepath.Join(root, "node_modules"), 0o755); err != nil { + t.Fatalf("mkdir node_modules: %v", err) + } + } + return filepath.Join(root, "dist", "bin", "vip.js") +} + +func TestResolveNodeVipBinReadyWhenEverythingPresent(t *testing.T) { + path := nodeCLILayout(t, true, true) + + got := ResolveNodeVipBin(path, nodeProbeFor(true)) + + if !got.Ready { + t.Fatalf("Ready = false, want true (reason: %s)", got.Reason) + } + if got.Path != path { + t.Errorf("Path = %q, want %q", got.Path, path) + } + if got.Reason != "" { + t.Errorf("Reason = %q, want empty when ready", got.Reason) + } +} + +func TestResolveNodeVipBinUnsetNamesTheVariable(t *testing.T) { + got := ResolveNodeVipBin("", nodeProbeFor(true)) + + if got.Ready { + t.Fatal("Ready = true, want false when NODE_VIP_BIN is unset") + } + if !strings.Contains(got.Reason, "NODE_VIP_BIN") { + t.Errorf("Reason = %q, want it to name NODE_VIP_BIN", got.Reason) + } +} + +func TestResolveNodeVipBinMissingBuildNamesPathAndBuildCommand(t *testing.T) { + path := nodeCLILayout(t, false, true) + + got := ResolveNodeVipBin(path, nodeProbeFor(true)) + + if got.Ready { + t.Fatal("Ready = true, want false when the Node CLI is not built") + } + if !strings.Contains(got.Reason, path) { + t.Errorf("Reason = %q, want it to name the missing path %q", got.Reason, path) + } + if !strings.Contains(got.Reason, "npm run build") { + t.Errorf("Reason = %q, want it to name `npm run build`", got.Reason) + } +} + +func TestResolveNodeVipBinMissingNodeModulesNamesNpmCi(t *testing.T) { + path := nodeCLILayout(t, true, false) + + got := ResolveNodeVipBin(path, nodeProbeFor(true)) + + if got.Ready { + t.Fatal("Ready = true, want false when node_modules is absent") + } + if !strings.Contains(got.Reason, "node_modules") { + t.Errorf("Reason = %q, want it to name node_modules", got.Reason) + } + if !strings.Contains(got.Reason, "npm ci") { + t.Errorf("Reason = %q, want it to name `npm ci`", got.Reason) + } +} + +func TestResolveNodeVipBinMissingNodeInterpreter(t *testing.T) { + path := nodeCLILayout(t, true, true) + + got := ResolveNodeVipBin(path, nodeProbeFor(false)) + + if got.Ready { + t.Fatal("Ready = true, want false when node is not on PATH") + } + if !strings.Contains(got.Reason, "node") { + t.Errorf("Reason = %q, want it to name the missing node interpreter", got.Reason) + } +} + +// TestResolveNodeVipBinRejectsDirectories guards against NODE_VIP_BIN pointing +// at dist/bin instead of dist/bin/vip.js. +func TestResolveNodeVipBinRejectsDirectories(t *testing.T) { + path := nodeCLILayout(t, true, true) + dir := filepath.Dir(path) + + got := ResolveNodeVipBin(dir, nodeProbeFor(true)) + + if got.Ready { + t.Fatal("Ready = true, want false when NODE_VIP_BIN is a directory") + } + if !strings.Contains(got.Reason, "not a file") { + t.Errorf("Reason = %q, want it to say the path is not a file", got.Reason) + } +} + +// TestNodeVipBinIsWiredForThisCheckout is the honesty check for the whole +// milestone: `make test-parity-unit` sets NODE_VIP_BIN, and in a checkout that +// has actually run `npm ci && npm run build` the Node-vs-Go scenario must RUN, +// not skip. When it cannot run, this test says loudly what is missing rather +// than reporting a silent pass. +func TestNodeVipBinIsWiredForThisCheckout(t *testing.T) { + status := ResolveNodeVipBin(os.Getenv("NODE_VIP_BIN"), DefaultNodeVipBinProbe()) + if status.Ready { + return + } + t.Skip(LoudSkip("Node-vs-Go differential coverage is OFF", status.Reason)) +} diff --git a/internal/parity/parker.go b/internal/parity/parker.go new file mode 100644 index 000000000..cef9f350b --- /dev/null +++ b/internal/parity/parker.go @@ -0,0 +1,491 @@ +//go:build parity + +package parity + +import ( + "context" + "errors" + "fmt" + "os" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + // ParkerAPIHost is `localhost`, NOT 127.0.0.1, and the distinction is + // load-bearing. Node derives its keychain service name from API_HOST + // (Token.getServiceName), so 127.0.0.1:4000 and localhost:4000 are two + // DIFFERENT credentials for the same server. Nothing can seed the + // 127.0.0.1 one: the parity keychain guard refuses it (port 4000 is below + // the ephemeral floor) precisely because vip-go-cli:http---127-0-0-1-4000 + // is the namespace a developer's own local-Parker login lives in — writing + // there would clobber it and the cleanup would delete it. + // + // `localhost` is where `vip login` against a local Parker actually puts the + // token, so Node finds a real credential with no seeding at all. Both + // resolve to the same loopback server; verified answering on both. + ParkerAPIHost = "http://localhost:4000" + ParkerContainer = "parker_app" + //nolint:gosec // G101: a path to a helper script, not a credential. + ParkerTokenScript = "/Users/rinat/projects/vip-go-platform-stack/vip-go-api/api-wpvip-com/bin/generate-token.sh" + // DefaultParkerUserID is the VIP Sys Admin in Parker's canonical seed data. + // It is a default, not a pin: override it with VIP_PARKER_USER_ID when the + // local seed changes. + DefaultParkerUserID = "1" + ParkerStartHelp = "cd /Users/rinat/projects/vip-go-platform-stack/vip-go-api && docker-compose --profile parker up" +) + +// ParkerUserID returns the local-Parker user the gate authenticates as. +// +// Only digits are accepted: the value is passed to generate-token.sh, and this +// keeps a stray shell metacharacter out of an argv that runs inside a +// container. +func ParkerUserID() string { + if v := strings.TrimSpace(os.Getenv("VIP_PARKER_USER_ID")); v != "" { + if _, err := strconv.Atoi(v); err == nil { + return v + } + } + return DefaultParkerUserID +} + +var ( + parkerTokenLine = regexp.MustCompile(`^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$`) + jwtInText = regexp.MustCompile(`[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+`) + + // These variables can independently change whether Node or Go emits ANSI + // escapes. They must be absent rather than empty: @colors/colors treats the + // mere presence of COLORTERM and an empty FORCE_COLOR as color support. + parkerColorEnvKeys = map[string]struct{}{ + "CI": {}, + "CI_NAME": {}, + "CLICOLOR": {}, + "CLICOLOR_FORCE": {}, + "COLORTERM": {}, + "FORCE_COLOR": {}, + "NO_COLOR": {}, + "TEAMCITY_VERSION": {}, + "TERM_PROGRAM": {}, + "TERM_PROGRAM_VERSION": {}, + } +) + +const ( + parkerAppIDPlaceholder = "{{app_id}}" + parkerAliasPlaceholder = "@{{app_name}}.{{env_identifier}}" +) + +type ParkerContext struct { + AppID int64 + AppName string + EnvID int64 + EnvIdentifier string +} + +type parkerScenarioDefinition struct { + FileName string + Name string + Argv []string +} + +var localParkerScenarioMatrix = []parkerScenarioDefinition{ + {FileName: "app-get-csv.yaml", Name: "local-parker-app-get-csv", Argv: []string{"app", parkerAppIDPlaceholder, "--format=csv"}}, + {FileName: "app-get-json.yaml", Name: "local-parker-app-get-json", Argv: []string{"app", parkerAppIDPlaceholder, "--format=json"}}, + {FileName: "app-get-table.yaml", Name: "local-parker-app-get-table", Argv: []string{"app", parkerAppIDPlaceholder}}, + {FileName: "app-list-csv.yaml", Name: "local-parker-app-list-csv", Argv: []string{"app", "list", "--format=csv"}}, + {FileName: "app-list-json.yaml", Name: "local-parker-app-list-json", Argv: []string{"app", "list", "--format=json"}}, + {FileName: "app-list-table.yaml", Name: "local-parker-app-list-table", Argv: []string{"app", "list"}}, + {FileName: "envvar-list-csv.yaml", Name: "local-parker-envvar-list-csv", Argv: []string{parkerAliasPlaceholder, "config", "envvar", "list", "--format=csv"}}, + {FileName: "envvar-list-ids.yaml", Name: "local-parker-envvar-list-ids", Argv: []string{parkerAliasPlaceholder, "config", "envvar", "list", "--format=ids"}}, + {FileName: "envvar-list-json.yaml", Name: "local-parker-envvar-list-json", Argv: []string{parkerAliasPlaceholder, "config", "envvar", "list", "--format=json"}}, + {FileName: "envvar-list-keyvalue.yaml", Name: "local-parker-envvar-list-keyvalue", Argv: []string{parkerAliasPlaceholder, "config", "envvar", "list", "--format=keyValue"}}, + {FileName: "envvar-list-table.yaml", Name: "local-parker-envvar-list-table", Argv: []string{parkerAliasPlaceholder, "config", "envvar", "list"}}, + {FileName: "software-get-csv.yaml", Name: "local-parker-software-get-csv", Argv: []string{parkerAliasPlaceholder, "config", "software", "get", "--format=csv"}}, + {FileName: "software-get-json.yaml", Name: "local-parker-software-get-json", Argv: []string{parkerAliasPlaceholder, "config", "software", "get", "--format=json"}}, + {FileName: "software-get-table.yaml", Name: "local-parker-software-get-table", Argv: []string{parkerAliasPlaceholder, "config", "software", "get"}}, + {FileName: "whoami.yaml", Name: "local-parker-whoami", Argv: []string{"whoami"}}, +} + +func (c ParkerContext) Alias() string { + return "@" + strings.ToLower(c.AppName) + "." + strings.ToLower(c.EnvIdentifier) +} + +func ParkerTokenArgs() []string { + return []string{ParkerUserID(), "--cli"} +} + +func parkerAllowedArgv(appID, alias string) [][]string { + allowed := make([][]string, 0, len(localParkerScenarioMatrix)) + for _, definition := range localParkerScenarioMatrix { + argv := append([]string(nil), definition.Argv...) + for i, arg := range argv { + switch arg { + case parkerAppIDPlaceholder: + argv[i] = appID + case parkerAliasPlaceholder: + argv[i] = alias + } + } + allowed = append(allowed, argv) + } + return allowed +} + +func ValidateParkerScenarioMatrix(scenarios map[string]*Scenario) error { + if len(scenarios) != len(localParkerScenarioMatrix) { + return fmt.Errorf("local Parker scenario matrix count=%d, want %d", len(scenarios), len(localParkerScenarioMatrix)) + } + seenArgv := make(map[string]string, len(localParkerScenarioMatrix)) + seenNames := make(map[string]string, len(localParkerScenarioMatrix)) + for _, definition := range localParkerScenarioMatrix { + argvKey := strings.Join(definition.Argv, "\x00") + if previous, exists := seenArgv[argvKey]; exists { + return fmt.Errorf("local Parker canonical matrix duplicate argv in %s and %s", previous, definition.FileName) + } + seenArgv[argvKey] = definition.FileName + if previous, exists := seenNames[definition.Name]; exists { + return fmt.Errorf("local Parker canonical matrix duplicate name in %s and %s", previous, definition.FileName) + } + seenNames[definition.Name] = definition.FileName + + scenario, exists := scenarios[definition.FileName] + if !exists { + return fmt.Errorf("local Parker scenario matrix missing %s", definition.FileName) + } + if err := validateParkerScenarioCommon(scenario); err != nil { + return err + } + if scenario.Name != definition.Name { + return fmt.Errorf("local Parker scenario %s name_mismatch", definition.FileName) + } + if !equalStrings(scenario.Argv, definition.Argv) { + return fmt.Errorf("local Parker scenario %s argv_mismatch", definition.FileName) + } + } + return nil +} + +func validateParkerScenarioCommon(s *Scenario) error { + if s == nil { + return errors.New("local Parker scenario is nil") + } + if strings.TrimSpace(s.Name) == "" { + return errors.New("local Parker scenario requires a name") + } + if len(s.Env) != 0 { + return fmt.Errorf("local Parker scenario %q must not override environment variables", s.Name) + } + if s.Recording != "" { + return fmt.Errorf("local Parker scenario %q must not use recordings", s.Name) + } + if len(s.Normalize.Stdout) != 0 || len(s.Normalize.Stderr) != 0 { + return fmt.Errorf("local Parker scenario %q must compare unnormalized output", s.Name) + } + if s.Expect.ExitCode != 0 { + return fmt.Errorf("local Parker scenario %q must expect exit code 0", s.Name) + } + if s.ExpectedDrift != nil { + return fmt.Errorf("local Parker scenario %q must not declare expected drift", s.Name) + } + return nil +} + +func validateParkerArgv(s *Scenario, allowed [][]string) error { + if err := validateParkerScenarioCommon(s); err != nil { + return err + } + for _, argv := range allowed { + if equalStrings(s.Argv, argv) { + return nil + } + } + return fmt.Errorf("local Parker scenario %q uses non-allowlisted argv", s.Name) +} + +func ValidateParkerScenarioTemplate(s *Scenario) error { + return validateParkerArgv(s, parkerAllowedArgv(parkerAppIDPlaceholder, parkerAliasPlaceholder)) +} + +// ValidateParkerScenario is kept as the template-policy entry point for the +// existing live loader. The runner switches to the explicit template and +// resolved validators once context discovery is wired. +func ValidateParkerScenario(s *Scenario) error { + return ValidateParkerScenarioTemplate(s) +} + +func ResolveParkerScenario(s *Scenario, ctx ParkerContext) (*Scenario, error) { + if err := ValidateParkerScenarioTemplate(s); err != nil { + return nil, err + } + resolved := *s + resolved.Argv = append([]string(nil), s.Argv...) + for i, arg := range resolved.Argv { + switch arg { + case parkerAppIDPlaceholder: + resolved.Argv[i] = strconv.FormatInt(ctx.AppID, 10) + case parkerAliasPlaceholder: + resolved.Argv[i] = ctx.Alias() + } + } + if err := ValidateResolvedParkerScenario(&resolved, ctx); err != nil { + return nil, err + } + return &resolved, nil +} + +func ValidateResolvedParkerScenario(s *Scenario, ctx ParkerContext) error { + if ctx.AppID <= 0 || ctx.EnvID <= 0 || strings.TrimSpace(ctx.AppName) == "" || strings.TrimSpace(ctx.EnvIdentifier) == "" { + return errors.New("local Parker resolved scenario requires a complete discovered context") + } + return validateParkerArgv(s, parkerAllowedArgv(strconv.FormatInt(ctx.AppID, 10), ctx.Alias())) +} + +// BuildParkerEnv builds the environment for the live local-Parker gate. +// +// API_HOST stays on localhost so Node reads its normal local-Parker credential +// from the stable keychain namespace. The generated token authenticates Go and +// context discovery through the test-only override; the harness deliberately +// does not write or clean Node's stable developer credential. Color controls +// are scrubbed and TERM is pinned so byte comparisons do not depend on the +// launching terminal or CI provider. +func BuildParkerEnv(parent []string, token string) []string { + overrides := map[string]string{ + "API_HOST": ParkerAPIHost, + "NODE_ENV": "test", + "GO_ENV": "test", + "DO_NOT_TRACK": "1", + "VIP_TOKEN_OVERRIDE": token, + "HTTP_PROXY": "", + "HTTPS_PROXY": "", + "ALL_PROXY": "", + "VIP_PROXY": "", + "SOCKS_PROXY": "", + "TERM": "dumb", + "VIP_USE_SYSTEM_PROXY": "", + "http_proxy": "", + "https_proxy": "", + "all_proxy": "", + } + + out := make([]string, 0, len(parent)+len(overrides)) + for _, kv := range parent { + key, _, ok := strings.Cut(kv, "=") + if !ok { + continue + } + if _, scrubbed := parkerColorEnvKeys[key]; scrubbed { + continue + } + if _, pinned := overrides[key]; !pinned { + out = append(out, kv) + } + } + keys := make([]string, 0, len(overrides)) + for key := range overrides { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + out = append(out, key+"="+overrides[key]) + } + return out +} + +func ParseParkerTokenOutput(out []byte) (string, error) { + var matches []string + for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if parkerTokenLine.MatchString(line) { + matches = append(matches, line) + } + } + if len(matches) != 1 { + return "", errors.New("local Parker token helper did not return exactly one JWT") + } + return matches[0], nil +} + +func RedactSecrets(value string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "<redacted>") + } + } + return jwtInText.ReplaceAllString(value, "<redacted-jwt>") +} + +func AssessDrift(s *Scenario, diff *DiffResult) (expected bool, err error) { + if s == nil || diff == nil { + return false, errors.New("cannot assess local Parker drift without a scenario and diff") + } + if s.ExpectedDrift != nil && strings.TrimSpace(s.ExpectedDrift.Reason) == "" { + return false, fmt.Errorf("scenario %q expected drift requires a reason", s.Name) + } + if diff.Equal { + if s.ExpectedDrift != nil { + return false, fmt.Errorf("scenario %q has a stale expected-drift annotation", s.Name) + } + return false, nil + } + if s.ExpectedDrift != nil { + return true, nil + } + return false, fmt.Errorf("scenario %q has unexpected Node/Go drift", s.Name) +} + +type ParkerRunDeps struct { + Preflight func(context.Context) error + GenerateToken func(context.Context) (string, error) + DiscoverContext func(context.Context, string) (ParkerContext, error) + RunBinary func(RunSpec) (*RunResult, error) + ReportDiff func(name, redactedDiff string) +} + +type ParkerSummary struct { + Compared int + Equal int + ExpectedDrift int +} + +func RunParkerScenarios( + ctx context.Context, + scenarios []*Scenario, + nodeBin, goBin string, + parentEnv []string, + deps ParkerRunDeps, +) (ParkerSummary, error) { + var summary ParkerSummary + var unexpectedDrift []string + for _, scenario := range scenarios { + if err := ValidateParkerScenarioTemplate(scenario); err != nil { + return summary, err + } + } + if deps.Preflight == nil || deps.GenerateToken == nil || deps.DiscoverContext == nil || deps.RunBinary == nil { + return summary, errors.New("local Parker runner dependencies are incomplete") + } + if err := deps.Preflight(ctx); err != nil { + return summary, fmt.Errorf("local Parker preflight failed: %w", err) + } + token, err := deps.GenerateToken(ctx) + if err != nil { + return summary, fmt.Errorf("local Parker token generation failed for user %s (override with VIP_PARKER_USER_ID)", ParkerUserID()) + } + if !parkerTokenLine.MatchString(token) { + return summary, fmt.Errorf("local Parker token generation failed for user %s (override with VIP_PARKER_USER_ID)", ParkerUserID()) + } + discovered, err := deps.DiscoverContext(ctx, token) + if err != nil { + return summary, fmt.Errorf("local Parker context discovery failed: %s", RedactSecrets(err.Error(), token)) + } + resolved := make([]*Scenario, 0, len(scenarios)) + for _, template := range scenarios { + scenario, err := ResolveParkerScenario(template, discovered) + if err != nil { + return summary, err + } + resolved = append(resolved, scenario) + } + env := BuildParkerEnv(parentEnv, token) + + for _, scenario := range resolved { + nodeResult, err := deps.RunBinary(RunSpec{ + Binary: nodeBin, + Argv: append([]string(nil), scenario.Argv...), + Env: append([]string(nil), env...), + }) + if err != nil { + return summary, fmt.Errorf("scenario %q Node execution failed: %s", scenario.Name, RedactSecrets(err.Error(), token)) + } + goResult, err := deps.RunBinary(RunSpec{ + Binary: goBin, + Argv: append([]string(nil), scenario.Argv...), + Env: append([]string(nil), env...), + }) + if err != nil { + return summary, fmt.Errorf("scenario %q Go execution failed: %s", scenario.Name, RedactSecrets(err.Error(), token)) + } + if nodeResult == nil || goResult == nil { + return summary, fmt.Errorf("scenario %q runner returned no result", scenario.Name) + } + if nodeResult.ExitCode != scenario.Expect.ExitCode || goResult.ExitCode != scenario.Expect.ExitCode { + return summary, fmt.Errorf( + "scenario %q exit codes Node=%d Go=%d, want %d\n%s", + scenario.Name, nodeResult.ExitCode, goResult.ExitCode, scenario.Expect.ExitCode, + formatParkerRunDiagnostics(nodeResult, goResult, token), + ) + } + diff, err := Diff(scenario, nodeResult, goResult) + if err != nil { + return summary, fmt.Errorf("scenario %q diff failed: %w", scenario.Name, err) + } + summary.Compared++ + if !diff.Equal && deps.ReportDiff != nil { + deps.ReportDiff(scenario.Name, formatParkerDiff(diff, token)) + } + expected, err := AssessDrift(scenario, diff) + if err != nil { + if !diff.Equal { + unexpectedDrift = append(unexpectedDrift, scenario.Name) + continue + } + return summary, err + } + if expected { + summary.ExpectedDrift++ + } else { + summary.Equal++ + } + } + if len(unexpectedDrift) > 0 { + return summary, fmt.Errorf( + "unexpected Node/Go drift in %d scenario(s): %s", + len(unexpectedDrift), strings.Join(unexpectedDrift, ", "), + ) + } + return summary, nil +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func formatParkerDiff(diff *DiffResult, secrets ...string) string { + var parts []string + for _, delta := range []string{diff.ExitCodeDelta, diff.StdoutDelta, diff.StderrDelta} { + if delta != "" { + parts = append(parts, boundedParkerDiagnostic(delta, secrets...)) + } + } + return strings.Join(parts, "\n") +} + +func formatParkerRunDiagnostics(nodeResult, goResult *RunResult, secrets ...string) string { + return fmt.Sprintf( + "Node stdout:\n%s\nNode stderr:\n%s\nGo stdout:\n%s\nGo stderr:\n%s", + boundedParkerDiagnostic(nodeResult.Stdout, secrets...), boundedParkerDiagnostic(nodeResult.Stderr, secrets...), + boundedParkerDiagnostic(goResult.Stdout, secrets...), boundedParkerDiagnostic(goResult.Stderr, secrets...), + ) +} + +func boundedParkerDiagnostic(value string, secrets ...string) string { + return truncateDiagnostic(RedactSecrets(value, secrets...)) +} + +func truncateDiagnostic(value string) string { + const max = 4096 + if len(value) <= max { + return value + } + return value[:max] + "\n<truncated>" +} diff --git a/internal/parity/parker_discovery.go b/internal/parity/parker_discovery.go new file mode 100644 index 000000000..98b16e0ae --- /dev/null +++ b/internal/parity/parker_discovery.go @@ -0,0 +1,373 @@ +//go:build parity + +package parity + +import ( + "bytes" + "cmp" + "context" + jsontext "encoding/json/jsontext" + json "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strings" + "time" + + "github.com/Automattic/vip/internal/envalias" +) + +type parkerHTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +const ( + parkerContextPageSize = 100 + parkerDiscoveryBodyLimit = 2 << 20 +) + +const parkerContextsQuery = `query LocalParkerParityContexts($first: Int!) { + apps(first: $first) { + total + edges { + id + name + typeId + environments { id appId name type } + } + } +}` + +const parkerCandidateQuery = `query LocalParkerParityCandidate($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + id + softwareSettings { + wordpress { current { version } } + php { current { version } } + muplugins { current { version } } + nodejs { current { version } } + } + } + } +}` + +type parkerGraphQLError struct { + Message string `json:"message"` + Path []any `json:"path"` + // Parker (Apollo) usually leaves the top-level `path` empty and reports the + // resolver path under extensions.exception instead — a live GOOP outage + // arrives as {"message":"… (VIP: fetch failed)","extensions":{"exception": + // {"path":["apps"]}}}. Read both so the resolver name survives either shape. + Extensions *struct { + Exception *struct { + Path []any `json:"path"` + } `json:"exception"` + } `json:"extensions"` +} + +// resolverPath returns whichever path shape the server used, top-level first. +func (e parkerGraphQLError) resolverPath() []any { + if len(e.Path) > 0 { + return e.Path + } + if e.Extensions != nil && e.Extensions.Exception != nil { + return e.Extensions.Exception.Path + } + return nil +} + +// formatParkerGraphQLErrors renders a GraphQL errors[] payload into something +// diagnosable. Parker answers HTTP 200 with errors[] when a backing service is +// unreachable — GOOP not listening on :2999 surfaces as +// "(VIP: fetch failed)" on path ["apps"] — so this text is the only thing that +// distinguishes "the parity gate is broken" from "a backing service is down". +// Without it the caller reports a bare contexts_graphql_error and the reader +// has to go spelunking in container logs to learn anything at all. +// +// Redacted through RedactSecrets because Parker echoes request context into +// some error payloads and this string ends up in CI logs. +func formatParkerGraphQLErrors(errs []parkerGraphQLError, token string) string { + parts := make([]string, 0, len(errs)) + for _, e := range errs { + msg := RedactSecrets(e.Message, token) + if path := e.resolverPath(); len(path) > 0 { + segs := make([]string, 0, len(path)) + for _, p := range path { + segs = append(segs, fmt.Sprint(p)) + } + msg = fmt.Sprintf("%s (path: %s)", msg, strings.Join(segs, ".")) + } + parts = append(parts, msg) + } + return strings.Join(parts, "; ") +} + +type parkerCandidate struct { + AppID int64 + AppName string + TypeID int64 + EnvID int64 + EnvAppID int64 + EnvName string + EnvType string +} + +type parkerContextPage struct { + Apps *struct { + Total int64 `json:"total"` + Edges []*struct { + ID int64 `json:"id"` + Name string `json:"name"` + TypeID int64 `json:"typeId"` + Environments []*struct { + ID int64 `json:"id"` + AppID int64 `json:"appId"` + Name string `json:"name"` + Type string `json:"type"` + } `json:"environments"` + } `json:"edges"` + } `json:"apps"` +} + +type parkerSoftwareVersion struct { + Current *struct { + Version string `json:"version"` + } `json:"current"` +} + +type parkerCandidateData struct { + App *struct { + Environments []*struct { + ID int64 `json:"id"` + SoftwareSettings *struct { + Wordpress *parkerSoftwareVersion `json:"wordpress"` + PHP *parkerSoftwareVersion `json:"php"` + Muplugins *parkerSoftwareVersion `json:"muplugins"` + NodeJS *parkerSoftwareVersion `json:"nodejs"` + } `json:"softwareSettings"` + } `json:"environments"` + } `json:"app"` +} + +func postParkerQuery( + ctx context.Context, + doer parkerHTTPDoer, + endpoint, token, operation, query string, + variables map[string]any, + dst any, +) ([]parkerGraphQLError, error) { + body, err := json.Marshal(map[string]any{ + "operationName": operation, + "query": query, + "variables": variables, + }) + if err != nil { + return nil, errors.New("local Parker discovery request encoding failed") + } + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + strings.TrimRight(endpoint, "/")+"/graphql", + bytes.NewReader(body), + ) + if err != nil { + return nil, errors.New("local Parker discovery request construction failed") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := doer.Do(req) + if err != nil { + return nil, errors.New("local Parker discovery transport failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("local Parker discovery HTTP status %d", resp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, parkerDiscoveryBodyLimit+1)) + if err != nil || len(raw) > parkerDiscoveryBodyLimit { + return nil, errors.New("local Parker discovery response could not be read safely") + } + var envelope struct { + Data jsontext.Value `json:"data"` + Errors []parkerGraphQLError `json:"errors"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, errors.New("local Parker discovery response was malformed") + } + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + if len(envelope.Errors) > 0 { + return envelope.Errors, nil + } + return nil, errors.New("local Parker discovery response was missing data") + } + if err := json.Unmarshal(envelope.Data, dst); err != nil { + return nil, errors.New("local Parker discovery data was malformed") + } + return envelope.Errors, nil +} + +func discoverLocalParkerContext(ctx context.Context, token string) (ParkerContext, error) { + client := &http.Client{ + Transport: &http.Transport{Proxy: nil}, + Timeout: 10 * time.Second, + } + return discoverParkerContext(ctx, client, ParkerAPIHost, token) +} + +func discoverParkerContext( + ctx context.Context, + doer parkerHTTPDoer, + endpoint, token string, +) (ParkerContext, error) { + candidates, err := listParkerCandidates(ctx, doer, endpoint, token) + if err != nil { + return ParkerContext{}, err + } + slices.SortFunc(candidates, func(a, b parkerCandidate) int { + if n := cmp.Compare(a.AppID, b.AppID); n != 0 { + return n + } + return cmp.Compare(a.EnvID, b.EnvID) + }) + + for _, candidate := range candidates { + candidateCtx, ok := candidateContext(candidate) + if !ok { + continue + } + eligible, err := probeParkerCandidate(ctx, doer, endpoint, token, candidate) + if err != nil { + return ParkerContext{}, err + } + if eligible { + return candidateCtx, nil + } + } + return ParkerContext{}, errors.New("no_suitable_context") +} + +func listParkerCandidates( + ctx context.Context, + doer parkerHTTPDoer, + endpoint, token string, +) ([]parkerCandidate, error) { + var page parkerContextPage + errs, err := postParkerQuery(ctx, doer, endpoint, token, "LocalParkerParityContexts", parkerContextsQuery, map[string]any{ + "first": parkerContextPageSize, + }, &page) + if err != nil { + return nil, err + } + if len(errs) > 0 { + return nil, fmt.Errorf("contexts_graphql_error: %s", + formatParkerGraphQLErrors(errs, token)) + } + if page.Apps == nil || page.Apps.Total < 0 { + return nil, errors.New("malformed_context_page") + } + // Parker's App.query reports the global GOOP total before per-user access + // filtering, so accessible edge count may legitimately be smaller. + if int64(len(page.Apps.Edges)) > page.Apps.Total { + return nil, errors.New("edge_total_mismatch") + } + + candidates := []parkerCandidate{} + for _, app := range page.Apps.Edges { + if app == nil { + continue + } + for _, env := range app.Environments { + if env == nil { + continue + } + candidates = append(candidates, parkerCandidate{ + AppID: app.ID, AppName: app.Name, TypeID: app.TypeID, + EnvID: env.ID, EnvAppID: env.AppID, EnvName: env.Name, EnvType: env.Type, + }) + } + } + return candidates, nil +} + +func probeParkerCandidate( + ctx context.Context, + doer parkerHTTPDoer, + endpoint, token string, + candidate parkerCandidate, +) (bool, error) { + var data parkerCandidateData + errs, err := postParkerQuery(ctx, doer, endpoint, token, "LocalParkerParityCandidate", parkerCandidateQuery, map[string]any{ + "appId": candidate.AppID, + "envId": candidate.EnvID, + }, &data) + if err != nil { + return false, err + } + if len(errs) > 0 { + return false, nil + } + if data.App == nil { + return false, errors.New("malformed_candidate_response") + } + if len(data.App.Environments) != 1 || data.App.Environments[0] == nil { + return false, nil + } + env := data.App.Environments[0] + if env.ID != candidate.EnvID { + return false, errors.New("candidate_id_mismatch") + } + if !parkerSoftwareEligible(env.SoftwareSettings) { + return false, nil + } + return true, nil +} + +func parkerSoftwareEligible(settings *struct { + Wordpress *parkerSoftwareVersion `json:"wordpress"` + PHP *parkerSoftwareVersion `json:"php"` + Muplugins *parkerSoftwareVersion `json:"muplugins"` + NodeJS *parkerSoftwareVersion `json:"nodejs"` +}) bool { + if settings == nil { + return false + } + for _, software := range []*parkerSoftwareVersion{ + settings.Wordpress, settings.PHP, settings.Muplugins, settings.NodeJS, + } { + if software != nil && software.Current != nil && strings.TrimSpace(software.Current.Version) != "" { + return true + } + } + return false +} + +func parkerEnvironmentIdentifier(c parkerCandidate) string { + if c.EnvType == "" { + return "" + } + if c.EnvID == c.EnvAppID || c.EnvName == "" || c.EnvName == c.EnvType { + return c.EnvType + } + return c.EnvType + "." + c.EnvName +} + +func candidateContext(c parkerCandidate) (ParkerContext, bool) { + identifier := parkerEnvironmentIdentifier(c) + ctx := ParkerContext{ + AppID: c.AppID, AppName: c.AppName, + EnvID: c.EnvID, EnvIdentifier: identifier, + } + if ctx.AppID <= 0 || ctx.EnvID <= 0 || strings.TrimSpace(ctx.AppName) == "" || identifier == "" { + return ParkerContext{}, false + } + rewritten, app, env, err := envalias.Rewrite([]string{ctx.Alias()}) + if err != nil || len(rewritten) != 0 || app != strings.ToLower(ctx.AppName) || env != strings.ToLower(identifier) { + return ParkerContext{}, false + } + return ctx, true +} diff --git a/internal/parity/parker_discovery_test.go b/internal/parity/parker_discovery_test.go new file mode 100644 index 000000000..7c9cfee44 --- /dev/null +++ b/internal/parity/parker_discovery_test.go @@ -0,0 +1,267 @@ +//go:build parity + +package parity + +import ( + "context" + json "encoding/json/v2" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type parkerDiscoveryRequest struct { + OperationName string `json:"operationName"` + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +func decodeParkerDiscoveryRequest(t *testing.T, r *http.Request) parkerDiscoveryRequest { + t.Helper() + if r.URL.Path != "/graphql" { + t.Fatalf("request path = %q, want /graphql", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer "+parkerTestToken { + t.Fatalf("authorization header missing") + } + var req parkerDiscoveryRequest + if err := json.UnmarshalRead(r.Body, &req); err != nil { + t.Fatal(err) + } + return req +} + +func TestDiscoverParkerContextUsesBoundedCatalogSortsAndProbes(t *testing.T) { + var catalogCalls, probeCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := decodeParkerDiscoveryRequest(t, r) + w.Header().Set("Content-Type", "application/json") + switch req.OperationName { + case "LocalParkerParityContexts": + catalogCalls++ + if catalogCalls > 1 { + t.Fatal("discovery requested more than one bounded catalog page") + } + if _, hasAfter := req.Variables["after"]; hasAfter { + t.Fatalf("bounded catalog variables unexpectedly include after: %v", req.Variables) + } + _, _ = io.WriteString(w, `{"data":{"apps":{"total":3,"nextCursor":"parker-always-sets-this","edges":[{"id":20,"name":"Later","typeId":2,"environments":[{"id":21,"appId":20,"name":"develop","type":"develop"}]},{"id":10,"name":"Chosen-App","typeId":2,"environments":[{"id":11,"appId":10,"name":"demo","type":"develop"}]}]}}}`) + case "LocalParkerParityCandidate": + probeCalls++ + if strings.Contains(req.Query, "environmentVariables") { + t.Fatal("candidate discovery must not read environment-variable metadata") + } + if fmt.Sprint(req.Variables["appId"]) == "10" { + _, _ = io.WriteString(w, `{"data":{"app":null},"errors":[{"message":"unsupported candidate"}]}`) + return + } + _, _ = io.WriteString(w, `{"data":{"app":{"environments":[{"id":21,"environmentVariables":{"total":1,"nodes":[{"name":"SAFE_NAME"}]},"softwareSettings":{"wordpress":{"current":{"version":"7.0"}},"php":null,"muplugins":null,"nodejs":null}}]}}}`) + default: + t.Fatalf("unexpected operation %q", req.OperationName) + } + })) + defer srv.Close() + + ctx, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err != nil { + t.Fatal(err) + } + want := ParkerContext{AppID: 20, AppName: "Later", EnvID: 21, EnvIdentifier: "develop"} + if ctx != want { + t.Fatalf("context = %+v, want %+v", ctx, want) + } + if catalogCalls != 1 || probeCalls != 2 { + t.Fatalf("calls catalog=%d probe=%d", catalogCalls, probeCalls) + } +} + +func TestDiscoverParkerContextRejectsMoreEdgesThanTotal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := decodeParkerDiscoveryRequest(t, r) + if req.OperationName != "LocalParkerParityContexts" { + t.Fatalf("unexpected operation %q", req.OperationName) + } + _, _ = io.WriteString(w, `{"data":{"apps":{"total":1,"nextCursor":"ignored","edges":[{"id":1,"name":"one","typeId":2,"environments":[]},{"id":2,"name":"two","typeId":2,"environments":[]}]}}}`) + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil || !strings.Contains(err.Error(), "edge_total_mismatch") { + t.Fatalf("error = %v, want edge_total_mismatch", err) + } +} + +func TestDiscoverParkerContextReturnsNoSuitableContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := decodeParkerDiscoveryRequest(t, r) + switch req.OperationName { + case "LocalParkerParityContexts": + _, _ = io.WriteString(w, `{"data":{"apps":{"total":1000,"nextCursor":"ignored","edges":[{"id":0,"name":"malformed","typeId":2,"environments":[{"id":1,"appId":0,"name":"production","type":"production"}]},{"id":2,"name":"valid","typeId":2,"environments":[{"id":2,"appId":2,"name":"production","type":"production"}]}]}}}`) + case "LocalParkerParityCandidate": + _, _ = io.WriteString(w, `{"data":{"app":{"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":""}]},"softwareSettings":{"wordpress":null,"php":null,"muplugins":null,"nodejs":null}}]}}}`) + default: + t.Fatalf("unexpected operation %q", req.OperationName) + } + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil || !strings.Contains(err.Error(), "no_suitable_context") { + t.Fatalf("error = %v, want no_suitable_context", err) + } +} + +func TestDiscoverParkerContextAllowsEmptyEnvvarCatalog(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := decodeParkerDiscoveryRequest(t, r) + switch req.OperationName { + case "LocalParkerParityContexts": + _, _ = io.WriteString(w, `{"data":{"apps":{"total":10,"edges":[{"id":1,"name":"alias-safe","typeId":2,"environments":[{"id":1,"appId":1,"name":"production","type":"production"}]}]}}}`) + case "LocalParkerParityCandidate": + _, _ = io.WriteString(w, `{"data":{"app":{"environments":[{"id":1,"environmentVariables":{"total":0,"nodes":[]},"softwareSettings":{"wordpress":{"current":{"version":"7.0"}},"php":null,"muplugins":null,"nodejs":null}}]}}}`) + default: + t.Fatalf("unexpected operation %q", req.OperationName) + } + })) + defer srv.Close() + + ctx, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err != nil { + t.Fatal(err) + } + want := ParkerContext{AppID: 1, AppName: "alias-safe", EnvID: 1, EnvIdentifier: "production"} + if ctx != want { + t.Fatalf("context=%+v, want %+v", ctx, want) + } +} + +func TestDiscoverParkerContextTreatsCandidateProtocolFailureAsFatal(t *testing.T) { + var probeCalls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req := decodeParkerDiscoveryRequest(t, r) + if req.OperationName == "LocalParkerParityContexts" { + _, _ = io.WriteString(w, `{"data":{"apps":{"total":1000,"nextCursor":"ignored","edges":[{"id":1,"name":"one","typeId":2,"environments":[{"id":1,"appId":1,"name":"production","type":"production"}]},{"id":2,"name":"two","typeId":2,"environments":[{"id":2,"appId":2,"name":"production","type":"production"}]}]}}}`) + return + } + probeCalls++ + http.Error(w, "protocol failure "+parkerTestToken, http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil || !strings.Contains(err.Error(), "HTTP status 500") { + t.Fatalf("error = %v, want sanitized HTTP failure", err) + } + if strings.Contains(err.Error(), parkerTestToken) { + t.Fatalf("error leaked token: %v", err) + } + if probeCalls != 1 { + t.Fatalf("probe calls = %d, want fatal stop after 1", probeCalls) + } +} + +func TestParkerEnvironmentIdentifier(t *testing.T) { + tests := []struct { + name string + in parkerCandidate + want string + }{ + {name: "primary", in: parkerCandidate{EnvID: 1, EnvAppID: 1, EnvName: "production", EnvType: "production"}, want: "production"}, + {name: "named child", in: parkerCandidate{EnvID: 2, EnvAppID: 1, EnvName: "demo", EnvType: "develop"}, want: "develop.demo"}, + {name: "native one-label child", in: parkerCandidate{EnvID: 2, EnvAppID: 1, EnvName: "develop", EnvType: "develop"}, want: "develop"}, + {name: "missing type", in: parkerCandidate{EnvID: 2, EnvAppID: 1, EnvName: "demo"}, want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := parkerEnvironmentIdentifier(tc.in); got != tc.want { + t.Fatalf("identifier = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCandidateContextRejectsAliasThatDoesNotRoundTrip(t *testing.T) { + _, ok := candidateContext(parkerCandidate{ + AppID: 1, AppName: "contains space", EnvID: 1, EnvAppID: 1, + EnvName: "production", EnvType: "production", + }) + if ok { + t.Fatal("candidate with a non-alias app name unexpectedly accepted") + } +} + +// A GraphQL errors[] payload carries the only useful diagnosis — when GOOP is +// down, Parker answers 200 with `(VIP: fetch failed)` and path ["apps"]. +// Discarding it turns "the backing service is down" into an opaque +// contexts_graphql_error and sends the reader hunting through container logs. +// Verified against a real local Parker: this exact payload cost six diagnostic +// steps that the message alone would have answered. +func TestListParkerCandidatesSurfacesGraphQLErrorText(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = decodeParkerDiscoveryRequest(t, r) + _, _ = io.WriteString(w, `{"errors":[{"message":"An unexpected error occurred while communicating with an internal service. (VIP: fetch failed)","path":["apps"]}],"data":{"apps":null}}`) + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil { + t.Fatal("err = nil, want a GraphQL error") + } + for _, want := range []string{"VIP: fetch failed", "apps"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error text missing %q — the server's diagnosis was discarded\n got: %v", want, err) + } + } +} + +// Surfacing server text must not become a token leak: Parker echoes request +// context into some error payloads, and the harness redacts JWTs everywhere +// else (RedactSecrets). A bearer token reaching the failure message would put +// it in CI logs. +func TestListParkerCandidatesRedactsTokensInGraphQLErrorText(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = decodeParkerDiscoveryRequest(t, r) + _, _ = io.WriteString(w, fmt.Sprintf( + `{"errors":[{"message":"denied for token %s","path":["apps"]}],"data":{"apps":null}}`, + parkerTestToken)) + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil { + t.Fatal("err = nil, want a GraphQL error") + } + if strings.Contains(err.Error(), parkerTestToken) { + t.Errorf("error text leaked the bearer token: %v", err) + } + // RedactSecrets replaces a known secret verbatim ("<redacted>") before the + // JWT regex ever runs ("<redacted-jwt>"), so accept either marker — the + // load-bearing assertion is the token-absence check above. + if !strings.Contains(err.Error(), "<redacted") { + t.Errorf("error text should carry a redaction marker; got: %v", err) + } +} + +// The payload shape a REAL local Parker returns when GOOP is down: the +// top-level `path` is absent and the resolver name lives under +// extensions.exception.path. Captured verbatim from a live run — reading only +// the top-level field silently drops the one word ("apps") that says which +// resolver failed. +func TestListParkerCandidatesReadsNestedExceptionPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = decodeParkerDiscoveryRequest(t, r) + _, _ = io.WriteString(w, `{"errors":[{"message":"An unexpected error occurred while communicating with an internal service. (VIP: fetch failed)","extensions":{"code":"INTERNAL_SERVER_ERROR","exception":{"message":"fetch failed","path":["apps"]}}}],"data":{"apps":null}}`) + })) + defer srv.Close() + + _, err := discoverParkerContext(context.Background(), srv.Client(), srv.URL, parkerTestToken) + if err == nil { + t.Fatal("err = nil, want a GraphQL error") + } + if !strings.Contains(err.Error(), "path: apps") { + t.Errorf("nested extensions.exception.path was dropped\n got: %v", err) + } +} diff --git a/internal/parity/parker_live_test.go b/internal/parity/parker_live_test.go new file mode 100644 index 000000000..1bb49df17 --- /dev/null +++ b/internal/parity/parker_live_test.go @@ -0,0 +1,164 @@ +//go:build parity && parker_parity + +package parity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/creack/pty" +) + +const parkerTokenOutputLimit = 16 * 1024 + +func TestLocalParkerParity(t *testing.T) { + nodeBin, err := requiredExecutable("NODE_VIP_BIN") + if err != nil { + t.Fatalf("local Parker preflight failed: %v\nstart Parker with: %s", err, ParkerStartHelp) + } + goBin, err := requiredExecutable("GO_VIP_BIN") + if err != nil { + t.Fatalf("local Parker preflight failed: %v\nstart Parker with: %s", err, ParkerStartHelp) + } + + paths, err := filepath.Glob("../../testdata/parity-local/*.yaml") + if err != nil { + t.Fatalf("glob local Parker scenarios: %v", err) + } + slices.Sort(paths) + if len(paths) != 15 { + t.Fatalf("local Parker scenario count=%d, want 15", len(paths)) + } + scenarios := make([]*Scenario, 0, len(paths)) + scenarioByFile := make(map[string]*Scenario, len(paths)) + for _, path := range paths { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("load local Parker scenario: %v", err) + } + scenarios = append(scenarios, scenario) + scenarioByFile[filepath.Base(path)] = scenario + } + if err := ValidateParkerScenarioMatrix(scenarioByFile); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + summary, err := RunParkerScenarios( + ctx, scenarios, nodeBin, goBin, os.Environ(), + ParkerRunDeps{ + Preflight: checkLocalParker, + GenerateToken: generateLocalParkerToken, + DiscoverContext: discoverLocalParkerContext, + RunBinary: Run, + ReportDiff: func(name, redactedDiff string) { + t.Logf("local Parker diff for %s:\n%s", name, redactedDiff) + }, + }, + ) + if err != nil { + if strings.Contains(err.Error(), "local Parker preflight failed") { + t.Fatalf("%v\nstart Parker with: %s", err, ParkerStartHelp) + } + t.Fatal(err) + } + if summary.Compared != 15 || summary.Equal != 15 || summary.ExpectedDrift != 0 { + t.Fatalf("local Parker parity: compared=%d equal=%d expected-drift=%d, want 15/15/0", + summary.Compared, summary.Equal, summary.ExpectedDrift) + } + t.Logf("local Parker parity: compared=%d equal=%d expected-drift=%d", + summary.Compared, summary.Equal, summary.ExpectedDrift) +} + +func requiredExecutable(envKey string) (string, error) { + path := os.Getenv(envKey) + if path == "" { + return "", fmt.Errorf("%s is not set", envKey) + } + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("%s is unavailable", envKey) + } + if info.IsDir() || info.Mode()&0o111 == 0 { + return "", fmt.Errorf("%s is not executable", envKey) + } + return path, nil +} + +func checkLocalParker(ctx context.Context) error { + out, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{json .NetworkSettings.Ports}}", ParkerContainer).Output() + if err != nil { + return fmt.Errorf("container %s is not inspectable", ParkerContainer) + } + var ports map[string][]struct { + HostPort string `json:"HostPort"` + } + if err := json.Unmarshal(out, &ports); err != nil { + return errors.New("container port bindings are not valid JSON") + } + found := false + for _, binding := range ports["4000/tcp"] { + if binding.HostPort == "4000" { + found = true + break + } + } + if !found { + return fmt.Errorf("container %s does not publish 4000/tcp on host port 4000", ParkerContainer) + } + + client := &http.Client{ + Transport: &http.Transport{Proxy: nil}, + Timeout: 5 * time.Second, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ParkerAPIHost, nil) + if err != nil { + return errors.New("could not construct the loopback Parker probe") + } + resp, err := client.Do(req) + if err != nil { + return errors.New("loopback Parker did not answer on 127.0.0.1:4000") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + return fmt.Errorf("loopback Parker returned HTTP %d, want 401", resp.StatusCode) + } + return nil +} + +func generateLocalParkerToken(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, ParkerTokenScript, ParkerTokenArgs()...) + ptmx, err := pty.Start(cmd) + if err != nil { + return "", errors.New("token helper could not start") + } + defer ptmx.Close() + + out, readErr := io.ReadAll(io.LimitReader(ptmx, parkerTokenOutputLimit+1)) + if len(out) > parkerTokenOutputLimit { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", errors.New("token helper output exceeded the safety limit") + } + waitErr := cmd.Wait() + if readErr != nil && !errors.Is(readErr, syscall.EIO) { + return "", errors.New("token helper output could not be read") + } + if waitErr != nil { + return "", errors.New("token helper failed") + } + return ParseParkerTokenOutput(out) +} diff --git a/internal/parity/parker_test.go b/internal/parity/parker_test.go new file mode 100644 index 000000000..eebb066ee --- /dev/null +++ b/internal/parity/parker_test.go @@ -0,0 +1,480 @@ +//go:build parity + +package parity + +import ( + "context" + "errors" + "path/filepath" + "slices" + "strings" + "testing" +) + +const parkerTestToken = "eyJhbGciOiJub25lIn0.eyJpZCI6MTAwMDB9.signature" + +func parkerScenario(argv ...string) *Scenario { + s := &Scenario{Name: "local-test", Argv: argv} + s.Expect.ExitCode = 0 + return s +} + +func TestLocalParkerScenarioFilesArePolicyCompliant(t *testing.T) { + scenarios := loadLocalParkerScenarioMap(t) + if err := ValidateParkerScenarioMatrix(scenarios); err != nil { + t.Fatal(err) + } +} + +func TestLocalParkerScenarioMatrixRejectsDuplicateArgvCoverage(t *testing.T) { + scenarios := loadLocalParkerScenarioMap(t) + duplicate := *scenarios["app-list-table.yaml"] + duplicate.Argv = []string{"whoami"} + scenarios["app-list-table.yaml"] = &duplicate + + err := ValidateParkerScenarioMatrix(scenarios) + if err == nil || !strings.Contains(err.Error(), "argv_mismatch") { + t.Fatalf("error = %v, want argv_mismatch", err) + } +} + +func loadLocalParkerScenarioMap(t *testing.T) map[string]*Scenario { + t.Helper() + paths, err := filepath.Glob("../../testdata/parity-local/*.yaml") + if err != nil { + t.Fatal(err) + } + scenarios := make(map[string]*Scenario, len(paths)) + for _, path := range paths { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + scenarios[filepath.Base(path)] = scenario + } + return scenarios +} + +func TestParkerTokenArgsDefaultsToSeededVIPAdmin(t *testing.T) { + t.Setenv("VIP_PARKER_USER_ID", "") + + got := ParkerTokenArgs() + want := []string{"1", "--cli"} + if !equalStrings(got, want) { + t.Fatalf("ParkerTokenArgs = %v, want %v", got, want) + } +} + +func TestParkerUserIDAcceptsNumericOverride(t *testing.T) { + t.Setenv("VIP_PARKER_USER_ID", "12") + + if got := ParkerUserID(); got != "12" { + t.Fatalf("ParkerUserID = %q, want %q", got, "12") + } +} + +func TestResolveParkerScenarioUsesOnlyDiscoveredContext(t *testing.T) { + template := parkerScenario(parkerAliasPlaceholder, "config", "software", "get", "--format=json") + ctx := ParkerContext{AppID: 42, AppName: "My-App", EnvID: 7, EnvIdentifier: "develop.demo"} + + resolved, err := ResolveParkerScenario(template, ctx) + if err != nil { + t.Fatal(err) + } + want := []string{"@my-app.develop.demo", "config", "software", "get", "--format=json"} + if !slices.Equal(resolved.Argv, want) { + t.Fatalf("argv = %v, want %v", resolved.Argv, want) + } + if slices.Equal(template.Argv, resolved.Argv) { + t.Fatal("ResolveParkerScenario mutated its template or did not resolve it") + } + if err := ValidateResolvedParkerScenario(resolved, ctx); err != nil { + t.Fatal(err) + } +} + +func TestParkerPolicyRejectsUnsafeMetadataAndDrift(t *testing.T) { + unsafe := [][]string{ + {parkerAliasPlaceholder, "logs"}, + {parkerAliasPlaceholder, "slowlogs"}, + {parkerAliasPlaceholder, "config", "envvar", "get", "SECRET"}, + {parkerAliasPlaceholder, "import", "sql", "status"}, + {parkerAliasPlaceholder, "cache", "purge-url", "https://example.test"}, + {"app", "list", "--format", "json"}, + {"whoami", "--debug"}, + } + for _, argv := range unsafe { + if err := ValidateParkerScenarioTemplate(parkerScenario(argv...)); err == nil { + t.Errorf("unsafe argv unexpectedly allowed: %v", argv) + } + } + + for name, mutate := range map[string]func(*Scenario){ + "environment override": func(s *Scenario) { s.Env = map[string]string{"API_HOST": "https://api.wpvip.com"} }, + "recording": func(s *Scenario) { s.Recording = "fixture.json" }, + "normalizer": func(s *Scenario) { + s.Normalize.Stdout = []NormalizeRule{{Pattern: "x", Replacement: "y"}} + }, + "expected drift": func(s *Scenario) { s.ExpectedDrift = &ExpectedDrift{Reason: "do not permit"} }, + } { + t.Run(name, func(t *testing.T) { + s := parkerScenario("whoami") + mutate(s) + if err := ValidateParkerScenarioTemplate(s); err == nil { + t.Fatalf("%s unexpectedly allowed", name) + } + }) + } +} + +func TestBuildParkerEnvPinsLoopbackAndClearsProxies(t *testing.T) { + parent := []string{ + "PATH=/usr/bin", + "API_HOST=https://api.wpvip.com", + "VIP_TOKEN_OVERRIDE=old-token", + "HTTP_PROXY=http://proxy.test", + "http_proxy=http://lower-proxy.test", + "VIP_PROXY=socks5://proxy.test", + } + env := envMap(BuildParkerEnv(parent, parkerTestToken)) + + for key, want := range map[string]string{ + "PATH": "/usr/bin", + "API_HOST": ParkerAPIHost, + "NODE_ENV": "test", + "GO_ENV": "test", + "DO_NOT_TRACK": "1", + "VIP_TOKEN_OVERRIDE": parkerTestToken, + "HTTP_PROXY": "", + "HTTPS_PROXY": "", + "ALL_PROXY": "", + "VIP_PROXY": "", + "SOCKS_PROXY": "", + "VIP_USE_SYSTEM_PROXY": "", + "http_proxy": "", + "https_proxy": "", + "all_proxy": "", + } { + if got := env[key]; got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } +} + +func TestBuildParkerEnvScrubsAmbientColorControls(t *testing.T) { + parent := []string{ + "PATH=/usr/bin", + "COLORTERM=", + "FORCE_COLOR=", + "CLICOLOR=1", + "CLICOLOR_FORCE=1", + "NO_COLOR=1", + "TERM=xterm-256color", + "TERM_PROGRAM=iTerm.app", + "TERM_PROGRAM_VERSION=3.5", + "CI=true", + "CI_NAME=codeship", + "TEAMCITY_VERSION=2025.1", + } + env := envMap(BuildParkerEnv(parent, parkerTestToken)) + + if got := env["TERM"]; got != "dumb" { + t.Fatalf("TERM = %q, want %q", got, "dumb") + } + for _, key := range []string{ + "COLORTERM", + "FORCE_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "NO_COLOR", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "CI", + "CI_NAME", + "TEAMCITY_VERSION", + } { + if got, exists := env[key]; exists { + t.Errorf("%s must be absent, got %q", key, got) + } + } +} + +func TestParseParkerTokenOutput(t *testing.T) { + got, err := ParseParkerTokenOutput([]byte("generating token\r\n" + parkerTestToken + "\r\n")) + if err != nil { + t.Fatalf("ParseParkerTokenOutput: %v", err) + } + if got != parkerTestToken { + t.Fatalf("token = %q, want test token", got) + } + + for _, out := range []string{ + "no token here", + parkerTestToken + "\n" + parkerTestToken, + "almost.a-token", + } { + _, err := ParseParkerTokenOutput([]byte(out)) + if err == nil { + t.Fatalf("ParseParkerTokenOutput(%q) unexpectedly succeeded", out) + } + if strings.Contains(err.Error(), out) || strings.Contains(err.Error(), parkerTestToken) { + t.Fatalf("error leaked helper output or token: %v", err) + } + } +} + +func TestRedactSecrets(t *testing.T) { + unknownJWT := "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6N30.unexpected-signature" + in := "authorization failed for " + parkerTestToken + " and second-secret; nested: " + unknownJWT + got := RedactSecrets(in, parkerTestToken, "second-secret", "") + if strings.Contains(got, parkerTestToken) || strings.Contains(got, "second-secret") || strings.Contains(got, unknownJWT) { + t.Fatalf("RedactSecrets leaked a secret: %q", got) + } + if strings.Count(got, "<redacted>") != 2 || !strings.Contains(got, "<redacted-jwt>") { + t.Fatalf("RedactSecrets = %q", got) + } +} + +func TestFormatParkerDiffRedactsThenBoundsEachDelta(t *testing.T) { + secretAtCutoff := strings.Repeat("x", 4080) + parkerTestToken + strings.Repeat("y", 5000) + diff := &DiffResult{ + ExitCodeDelta: strings.Repeat("e", 5000), + StdoutDelta: secretAtCutoff, + StderrDelta: secretAtCutoff, + } + got := formatParkerDiff(diff, parkerTestToken) + assertBoundedSecretDiagnostic(t, got, 3) +} + +func TestFormatParkerRunDiagnosticsRedactsThenBoundsEachStream(t *testing.T) { + secretAtCutoff := strings.Repeat("x", 4080) + parkerTestToken + strings.Repeat("y", 5000) + got := formatParkerRunDiagnostics( + &RunResult{Stdout: secretAtCutoff, Stderr: secretAtCutoff}, + &RunResult{Stdout: secretAtCutoff, Stderr: secretAtCutoff}, + parkerTestToken, + ) + assertBoundedSecretDiagnostic(t, got, 4) +} + +func assertBoundedSecretDiagnostic(t *testing.T, got string, sections int) { + t.Helper() + for _, secretFragment := range []string{ + parkerTestToken, + strings.Split(parkerTestToken, ".")[0], + strings.Split(parkerTestToken, ".")[1], + } { + if strings.Contains(got, secretFragment) { + t.Fatalf("diagnostic leaked token material %q", secretFragment) + } + } + if count := strings.Count(got, "<truncated>"); count != sections { + t.Fatalf("truncation markers = %d, want %d", count, sections) + } + const perSectionLimit = 4096 + len("\n<truncated>") + if max := sections*perSectionLimit + 256; len(got) > max { + t.Fatalf("diagnostic length = %d, want <= %d", len(got), max) + } +} + +func TestAssessDrift(t *testing.T) { + tests := []struct { + name string + equal bool + drift *ExpectedDrift + wantExpected bool + wantErr bool + }{ + {name: "equal", equal: true}, + {name: "unexpected difference", equal: false, wantErr: true}, + {name: "expected difference", equal: false, drift: &ExpectedDrift{Reason: "known ordering difference"}, wantExpected: true}, + {name: "stale annotation", equal: true, drift: &ExpectedDrift{Reason: "known ordering difference"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := parkerScenario("whoami") + s.ExpectedDrift = tc.drift + got, err := AssessDrift(s, &DiffResult{Equal: tc.equal}) + if (err != nil) != tc.wantErr { + t.Fatalf("AssessDrift error = %v, wantErr %v", err, tc.wantErr) + } + if got != tc.wantExpected { + t.Fatalf("AssessDrift expected = %v, want %v", got, tc.wantExpected) + } + }) + } +} + +func TestCheckParkerPreflightDoesNotRunCLIOnFailure(t *testing.T) { + generated := 0 + discovered := 0 + runs := 0 + summary, err := RunParkerScenarios( + context.Background(), + []*Scenario{parkerScenario("whoami")}, + "node-vip", "go-vip", nil, + ParkerRunDeps{ + Preflight: func(context.Context) error { return errors.New("not ready") }, + GenerateToken: func(context.Context) (string, error) { + generated++ + return parkerTestToken, nil + }, + DiscoverContext: func(context.Context, string) (ParkerContext, error) { + discovered++ + return ParkerContext{}, nil + }, + RunBinary: func(RunSpec) (*RunResult, error) { + runs++ + return &RunResult{}, nil + }, + }, + ) + if err == nil { + t.Fatal("preflight failure must be returned") + } + if generated != 0 || discovered != 0 || runs != 0 { + t.Fatalf("preflight failure generated=%d discovered=%d runs=%d, want zero", generated, discovered, runs) + } + if summary != (ParkerSummary{}) { + t.Fatalf("summary = %+v, want zero", summary) + } +} + +func TestRunParkerScenariosStopsBeforeCLIOnDiscoveryFailure(t *testing.T) { + var order []string + _, err := RunParkerScenarios( + context.Background(), + []*Scenario{parkerScenario("whoami")}, + "node-vip", "go-vip", nil, + ParkerRunDeps{ + Preflight: func(context.Context) error { + order = append(order, "preflight") + return nil + }, + GenerateToken: func(context.Context) (string, error) { + order = append(order, "token") + return parkerTestToken, nil + }, + DiscoverContext: func(context.Context, string) (ParkerContext, error) { + order = append(order, "discover") + return ParkerContext{}, errors.New("no context") + }, + RunBinary: func(RunSpec) (*RunResult, error) { + order = append(order, "run") + return &RunResult{}, nil + }, + }, + ) + if err == nil { + t.Fatal("expected discovery error") + } + if !slices.Equal(order, []string{"preflight", "token", "discover"}) { + t.Fatalf("order = %v", order) + } +} + +func TestRunParkerScenariosUsesOnePinnedEnvironment(t *testing.T) { + var specs []RunSpec + discoveries := 0 + ctx := ParkerContext{AppID: 42, AppName: "My-App", EnvID: 7, EnvIdentifier: "develop.demo"} + summary, err := RunParkerScenarios( + context.Background(), + []*Scenario{ + parkerScenario("app", parkerAppIDPlaceholder, "--format=json"), + parkerScenario(parkerAliasPlaceholder, "config", "software", "get", "--format=json"), + }, + "node-vip", "go-vip", []string{"API_HOST=https://api.wpvip.com"}, + ParkerRunDeps{ + Preflight: func(context.Context) error { return nil }, + GenerateToken: func(context.Context) (string, error) { return parkerTestToken, nil }, + DiscoverContext: func(context.Context, string) (ParkerContext, error) { + discoveries++ + return ctx, nil + }, + RunBinary: func(spec RunSpec) (*RunResult, error) { + specs = append(specs, spec) + return &RunResult{ExitCode: 0, Stdout: "same\n"}, nil + }, + }, + ) + if err != nil { + t.Fatalf("RunParkerScenarios: %v", err) + } + if summary.Compared != 2 || summary.Equal != 2 || summary.ExpectedDrift != 0 { + t.Fatalf("summary = %+v", summary) + } + if discoveries != 1 { + t.Fatalf("discoveries = %d, want 1", discoveries) + } + if len(specs) != 4 || specs[0].Binary != "node-vip" || specs[1].Binary != "go-vip" || specs[2].Binary != "node-vip" || specs[3].Binary != "go-vip" { + t.Fatalf("run order = %+v", specs) + } + wantArgv := [][]string{ + {"app", "42", "--format=json"}, + {"app", "42", "--format=json"}, + {"@my-app.develop.demo", "config", "software", "get", "--format=json"}, + {"@my-app.develop.demo", "config", "software", "get", "--format=json"}, + } + for i, spec := range specs { + if !slices.Equal(spec.Argv, wantArgv[i]) { + t.Fatalf("spec %d argv = %v, want %v", i, spec.Argv, wantArgv[i]) + } + } + for _, spec := range specs { + env := envMap(spec.Env) + if env["API_HOST"] != ParkerAPIHost || env["VIP_TOKEN_OVERRIDE"] != parkerTestToken { + t.Fatalf("unpinned run env: %+v", env) + } + } +} + +func TestRunParkerScenariosReportsAllUnexpectedDriftBeforeFailing(t *testing.T) { + var runs int + var reported []string + summary, err := RunParkerScenarios( + context.Background(), + []*Scenario{ + parkerScenario("whoami"), + parkerScenario("app", "list", "--format=json"), + }, + "node-vip", "go-vip", nil, + ParkerRunDeps{ + Preflight: func(context.Context) error { return nil }, + GenerateToken: func(context.Context) (string, error) { return parkerTestToken, nil }, + DiscoverContext: func(context.Context, string) (ParkerContext, error) { + return ParkerContext{AppID: 1, AppName: "one", EnvID: 1, EnvIdentifier: "production"}, nil + }, + RunBinary: func(spec RunSpec) (*RunResult, error) { + runs++ + stdout := "same\n" + if slices.Equal(spec.Argv, []string{"whoami"}) { + stdout = spec.Binary + "\n" + } + return &RunResult{ExitCode: 0, Stdout: stdout}, nil + }, + ReportDiff: func(name, _ string) { reported = append(reported, name) }, + }, + ) + if err == nil || !strings.Contains(err.Error(), "unexpected Node/Go drift") { + t.Fatalf("error = %v, want aggregate drift failure", err) + } + if runs != 4 { + t.Fatalf("runs = %d, want all 4 Node/Go runs", runs) + } + if summary.Compared != 2 || summary.Equal != 1 || summary.ExpectedDrift != 0 { + t.Fatalf("summary = %+v, want compared=2 equal=1", summary) + } + if !slices.Equal(reported, []string{"local-test"}) { + t.Fatalf("reported diffs = %v", reported) + } +} + +func envMap(env []string) map[string]string { + out := make(map[string]string, len(env)) + for _, kv := range env { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + out[parts[0]] = parts[1] + } + } + return out +} diff --git a/internal/parity/phpmyadmin_scenario_test.go b/internal/parity/phpmyadmin_scenario_test.go new file mode 100644 index 000000000..b8e477353 --- /dev/null +++ b/internal/parity/phpmyadmin_scenario_test.go @@ -0,0 +1,214 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" +) + +// phpmyadminMux returns a shared HTTP handler that answers the operations the +// db phpmyadmin flow can fire: +// +// - ResolveAppByName (WithAppContext) +// - PhpMyAdminStatus (the gate, then the poll) +// - EnablePhpMyAdmin (only when the gate says the env is not already up) +// - GeneratePhpMyAdminAccess +// +// Per-op response bodies are read from the per-scenario recording directory +// so each scenario can override (e.g. error scenario serves a GraphQL error +// from enable.json). +func phpmyadminMux(t *testing.T, recordingDir string) (http.Handler, func() (en, st, gn int32)) { + t.Helper() + read := func(name string) []byte { + b, err := os.ReadFile("../../testdata/parity/recordings/" + recordingDir + "/" + name) + if err != nil { + t.Fatalf("read %s/%s: %v", recordingDir, name, err) + } + return b + } + resolveAppBody := read("resolve-app.json") + enableBody := read("enable.json") + // Some scenarios (error) only have resolve-app + enable; status / generate + // would never be reached. Read them lazily. + maybeRead := func(name string) []byte { + b, err := os.ReadFile("../../testdata/parity/recordings/" + recordingDir + "/" + name) + if err != nil { + return nil + } + return b + } + statusBody := maybeRead("status.json") + generateBody := maybeRead("generate.json") + + var enableHits, statusHits, generateHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + w.Header().Set("Content-Type", "application/json") + switch { + // `App` is Node's name for the same app resolution Go spells + // ResolveAppByName/ByID (src/lib/api/app.ts:46,69). All three + // operation names must be routed, or the real Node CLI falls through + // to the default branch and dies resolving @parityapp.develop. + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + _, _ = w.Write(resolveAppBody) + case strings.Contains(s, `"operationName":"EnablePhpMyAdmin"`): + atomic.AddInt32(&enableHits, 1) + _, _ = w.Write(enableBody) + case strings.Contains(s, `"operationName":"PhpMyAdminStatus"`): + atomic.AddInt32(&statusHits, 1) + if statusBody == nil { + _, _ = w.Write([]byte(`{"data":null}`)) + return + } + _, _ = w.Write(statusBody) + case strings.Contains(s, `"operationName":"GeneratePhpMyAdminAccess"`): + atomic.AddInt32(&generateHits, 1) + if generateBody == nil { + _, _ = w.Write([]byte(`{"data":null}`)) + return + } + _, _ = w.Write(generateBody) + default: + _, _ = w.Write([]byte(`{"data":null}`)) + } + }) + hits := func() (int32, int32, int32) { + return atomic.LoadInt32(&enableHits), atomic.LoadInt32(&statusHits), atomic.LoadInt32(&generateHits) + } + return mux, hits +} + +// TestPhpmyadminPrintParity exercises the happy path with --print: the +// generated URL must land on stdout, exit code 0. +func TestPhpmyadminPrintParity(t *testing.T) { + mux, hits := phpmyadminMux(t, "phpmyadmin-print") + srv := httptest.NewServer(mux) + defer srv.Close() + + scenario, err := LoadScenario("../../testdata/parity/phpmyadmin-print.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + goBin := buildVipNextWithVersion(t, "test", "test") + res, err := Run(RunSpec{Binary: goBin, Argv: scenario.Argv, Env: FixtureEnv(scenario.Env)}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0; stderr=%s; stdout=%s", res.ExitCode, res.Stderr, res.Stdout) + } + // The recording reports status "running", so Node's maybeEnablePhpMyAdmin + // (phpmyadmin.ts:213-222) short-circuits: the environment is already up, + // so NO enable mutation is sent. Go used to fire it on every invocation. + en, st, gn := hits() + if en != 0 { + t.Errorf("enable hits = %d, want 0: status is already 'running'", en) + } + if st < 1 || gn != 1 { + t.Errorf("hits status/generate = %d/%d, want >=1/1", st, gn) + } + if !strings.Contains(res.Stdout, "https://pma.parity.example/abc") { + t.Errorf("stdout missing URL; got=%q", res.Stdout) + } +} + +// TestPhpmyadminSilentParity exercises --print --silent: URL still lands on +// stdout, stderr stays empty (no progress lines, no read-only warning). +func TestPhpmyadminSilentParity(t *testing.T) { + mux, _ := phpmyadminMux(t, "phpmyadmin-silent") + srv := httptest.NewServer(mux) + defer srv.Close() + + scenario, err := LoadScenario("../../testdata/parity/phpmyadmin-silent.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + goBin := buildVipNextWithVersion(t, "test", "test") + res, err := Run(RunSpec{Binary: goBin, Argv: scenario.Argv, Env: FixtureEnv(scenario.Env)}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0; stderr=%s; stdout=%s", res.ExitCode, res.Stderr, res.Stdout) + } + if !strings.Contains(res.Stdout, "https://pma.parity.example/silent") { + t.Errorf("stdout missing URL; got=%q", res.Stdout) + } + // Ambient environment noise is not something --silent has any say over: on + // a headless host the keychain reports its file fallback before the command + // ever runs. Strip the same rules the differ uses (ambientStderrRules) so + // this assertion tests the flag rather than the runner. + // + // Whether --silent *ought* to suppress that notice too is a real question + // about the flag's contract, and a separate one from this test. + silentStderr, err := normalizeStderr(res.Stderr, nil) + if err != nil { + t.Fatalf("normalizeStderr: %v", err) + } + if strings.TrimSpace(silentStderr) != "" { + t.Errorf("--silent must suppress all stderr; got=%q", silentStderr) + } +} + +// TestPhpmyadminErrorParity: enable mutation returns a GraphQL error; +// the CLI must exit non-zero. +func TestPhpmyadminErrorParity(t *testing.T) { + mux, hits := phpmyadminMux(t, "phpmyadmin-error") + srv := httptest.NewServer(mux) + defer srv.Close() + + scenario, err := LoadScenario("../../testdata/parity/phpmyadmin-error.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + goBin := buildVipNextWithVersion(t, "test", "test") + res, err := Run(RunSpec{Binary: goBin, Argv: scenario.Argv, Env: FixtureEnv(scenario.Env)}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode == 0 { + t.Errorf("exit = 0, want non-zero (enable mutation errored); stderr=%s; stdout=%s", + res.Stderr, res.Stdout) + } + // The status query now runs FIRST (it is what decides whether to enable + // at all); this recording has no status.json, so the mux answers + // `{"data":null}` — an unknown status — which is what sends us into the + // enable branch. Generate must still never run after the enable error. + en, st, gn := hits() + if st != 1 { + t.Errorf("status must be queried exactly once before enabling; got %d", st) + } + if en != 1 { + t.Errorf("enable must be attempted for an unknown status; got %d", en) + } + if gn != 0 { + t.Errorf("generate must not be called after enable error; got %d", gn) + } +} diff --git a/internal/parity/runner.go b/internal/parity/runner.go new file mode 100644 index 000000000..5d2db1b9e --- /dev/null +++ b/internal/parity/runner.go @@ -0,0 +1,54 @@ +//go:build parity + +package parity + +import ( + "bytes" + "errors" + "os/exec" +) + +type RunSpec struct { + Binary string + Argv []string + Env []string // KEY=VALUE + Stdin []byte +} + +type RunResult struct { + ExitCode int + Stdout string + Stderr string +} + +// Run executes Binary with Argv and captures stdout, stderr, and the exit code. +// A non-zero exit is NOT returned as a Go error — it's a normal result that +// the diff engine will compare against the expected value. Real errors +// (binary not found, etc.) are returned as errors. +func Run(spec RunSpec) (*RunResult, error) { + cmd := exec.Command(spec.Binary, spec.Argv...) + cmd.Env = spec.Env + if len(spec.Stdin) > 0 { + cmd.Stdin = bytes.NewReader(spec.Stdin) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + res := &RunResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + } + if err == nil { + res.ExitCode = 0 + return res, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, nil + } + return nil, err +} diff --git a/internal/parity/runner_test.go b/internal/parity/runner_test.go new file mode 100644 index 000000000..6a33f4c37 --- /dev/null +++ b/internal/parity/runner_test.go @@ -0,0 +1,43 @@ +//go:build parity + +package parity + +import ( + "strings" + "testing" +) + +func TestRunCapturesStdoutAndExit(t *testing.T) { + // Use `go env GOVERSION` as a trivially available command that prints + // to stdout and exits 0 on every supported platform. + res, err := Run(RunSpec{ + Binary: "go", + Argv: []string{"env", "GOVERSION"}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", res.ExitCode) + } + if !strings.HasPrefix(res.Stdout, "go1.") { + t.Errorf("Stdout = %q, want prefix go1.", res.Stdout) + } +} + +func TestRunCapturesNonZeroExit(t *testing.T) { + // `go env --bogus-flag` exits non-zero. + res, err := Run(RunSpec{ + Binary: "go", + Argv: []string{"env", "--bogus-flag-no-such"}, + }) + if err != nil { + t.Fatalf("Run should not return an error on non-zero exit: %v", err) + } + if res.ExitCode == 0 { + t.Errorf("expected non-zero exit, got 0") + } + if res.Stderr == "" { + t.Errorf("expected stderr output, got empty") + } +} diff --git a/internal/parity/scenario.go b/internal/parity/scenario.go new file mode 100644 index 000000000..6bdb87d02 --- /dev/null +++ b/internal/parity/scenario.go @@ -0,0 +1,82 @@ +//go:build parity + +// Package parity hosts the differential harness that runs vip-next (and, +// once M2 introduces real commands to diff, the Node vip binary) against +// scripted scenarios and compares their output. +// +// This file: scenario loading from YAML files in testdata/parity/. +package parity + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type NormalizeRule struct { + Pattern string + Replacement string +} + +type ExpectedDrift struct { + Reason string `yaml:"reason"` + Signature string `yaml:"signature"` +} + +func (r *NormalizeRule) UnmarshalYAML(node *yaml.Node) error { + var s string + if err := node.Decode(&s); err != nil { + return err + } + parts := strings.SplitN(s, " -> ", 2) + if len(parts) != 2 { + return fmt.Errorf("normalize rule %q must be 'pattern -> replacement'", s) + } + r.Pattern = parts[0] + r.Replacement = parts[1] + return nil +} + +type Scenario struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Argv []string `yaml:"argv"` + Env map[string]string `yaml:"env"` + Recording string `yaml:"recording"` + Normalize struct { + Stdout []NormalizeRule `yaml:"stdout"` + Stderr []NormalizeRule `yaml:"stderr"` + } `yaml:"normalize"` + Expect struct { + ExitCode int `yaml:"exit_code"` + } `yaml:"expect"` + ExpectedDrift *ExpectedDrift `yaml:"expected_drift"` +} + +func LoadScenario(path string) (*Scenario, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + s := &Scenario{} + if err := yaml.Unmarshal(data, s); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if s.Name == "" { + return nil, fmt.Errorf("scenario %s missing name", path) + } + if s.ExpectedDrift != nil && strings.TrimSpace(s.ExpectedDrift.Reason) == "" { + return nil, fmt.Errorf("scenario %s expected_drift requires a non-empty reason", path) + } + if s.ExpectedDrift != nil { + sig := strings.TrimSpace(s.ExpectedDrift.Signature) + decoded, err := hex.DecodeString(sig) + if err != nil || len(decoded) != 32 || sig != strings.ToLower(sig) { + return nil, fmt.Errorf("scenario %s expected_drift requires a lowercase 64-character signature", path) + } + } + return s, nil +} diff --git a/internal/parity/scenario_test.go b/internal/parity/scenario_test.go new file mode 100644 index 000000000..83bc59d70 --- /dev/null +++ b/internal/parity/scenario_test.go @@ -0,0 +1,52 @@ +//go:build parity + +package parity + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadScenarioVersionSmoke(t *testing.T) { + s, err := LoadScenario("../../testdata/parity/version-smoke.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + if s.Name != "version-smoke" { + t.Errorf("Name = %q, want %q", s.Name, "version-smoke") + } + if len(s.Argv) != 1 || s.Argv[0] != "--version" { + t.Errorf("Argv = %v, want [--version]", s.Argv) + } + if s.Expect.ExitCode != 0 { + t.Errorf("Expect.ExitCode = %d, want 0", s.Expect.ExitCode) + } + if len(s.Normalize.Stdout) != 1 { + t.Errorf("Normalize.Stdout has %d entries, want 1", len(s.Normalize.Stdout)) + } +} + +func TestLoadScenarioRejectsExpectedDriftWithoutReason(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing-reason.yaml") + if err := os.WriteFile(path, []byte("name: missing-reason\nargv: [whoami]\nexpected_drift: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadScenario(path) + if err == nil || !strings.Contains(err.Error(), "non-empty reason") { + t.Fatalf("LoadScenario error = %v, want non-empty reason error", err) + } +} + +func TestLoadScenarioRejectsExpectedDriftWithoutSignature(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing-signature.yaml") + data := "name: missing-signature\nargv: [whoami]\nexpected_drift:\n reason: intentional output difference\n" + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + _, err := LoadScenario(path) + if err == nil || !strings.Contains(err.Error(), "64-character signature") { + t.Fatalf("LoadScenario error = %v, want missing signature error", err) + } +} diff --git a/internal/parity/surface_differential_test.go b/internal/parity/surface_differential_test.go new file mode 100644 index 000000000..e94c94126 --- /dev/null +++ b/internal/parity/surface_differential_test.go @@ -0,0 +1,491 @@ +//go:build parity + +package parity + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// The Node-vs-Go differential for everything OUTSIDE the M5 command surface. +// +// WHY THIS FILE EXISTS +// +// testdata/parity/ holds 85 scenarios. Until this file, 31 of them (M5, via +// m5_differential_test.go) plus TestWhoamiBaselineParity ran BOTH CLIs and +// diffed them. The other ~53 spawned vip-next ONLY, against an httptest mock, +// and asserted with strings.Contains — Go-behaviour tests wearing a parity +// build tag. Every one of them passes just as happily when Go and Node +// disagree, which is how ~90 divergences survived to a manual review. +// +// TestSurfaceDifferentialParity is the same rig as M5's, applied to the rest: +// one shared httptest server, one seeded Node credential for the whole test +// binary (see differential_test.go and keychain.go for why that is not +// negotiable), stdout + stderr + exit code compared byte for byte. +// +// WHAT MAKES A SCENARIO CONVERTIBLE +// +// The three M5 conditions still hold — side-effect free at the mock, every +// operation BOTH CLIs issue is served, no credential beyond the seeded one — +// plus two this surface adds: +// +// 4. the argv must be VALID FOR NODE. Several scenarios were written against +// vip-next's flag set and Node rejects them outright ("The option +// 'skip-confirmation' is unknown"); running those compares a working +// command against a usage error, which measures nothing. +// 5. a poll loop must terminate on fixtures alone. Node's intervals are +// hardcoded — 1000ms for backup db and export sql (src/commands/backup-db.ts:18, +// src/commands/export-sql.ts:34), 5000ms for import sql +// (src/lib/site-import/status.ts:25), plus an unconditional 30s sleep after +// enabling phpMyAdmin (src/commands/phpmyadmin.ts:220). Node honours no +// VIP_*_INTERVAL_MS; those variables are vip-next's alone. A scenario whose +// fixtures reach a terminal state on the first response costs nothing, one +// that needs five polls costs five seconds of every CI run. +// +// Anything failing one of the five is listed in surfaceDifferentialExclusions +// with the specific reason. TestEverySurfaceScenarioIsClassified fails when a +// scenario appears in neither map, so a new YAML cannot quietly opt out. +// +// ON FAILING SUBTESTS +// +// A divergence here is a FINDING, not a bug in the harness, and it is left +// RED on purpose. It may only be downgraded by an expected_drift annotation in +// the scenario YAML, which records both the product decision and an exact +// normalized-output fingerprint. A changed fingerprint is red again. + +// surfaceMuxFactory builds a FRESH handler for one binary's run, together with +// an accessor for that handler's own mutation counters. +// +// Freshness is the point. Every family mux counts mutations with an atomic, and +// a differential runs the command TWICE against one server. Sharing a handler +// would make "StartImport fired 0 times" mean "fired 0 times across both CLIs", +// which is a strictly weaker claim and silently tolerates one CLI firing twice +// while the other fires never. Building the mux per side keeps each count a +// statement about a single implementation. +type surfaceMuxFactory func(t *testing.T, recordingDir string) (http.Handler, func() map[string]int32) + +// nullMux answers every request with {"data":null}. +// +// It is for the LOCAL-ONLY commands — validate-sql, app deploy validate — which +// make no API call at all. It is deliberately not "no handler": the rig's +// default handler answers 500 with a marker body, and a command that +// unexpectedly starts calling the API should show up as a diff rather than as a +// connection error that looks the same on both sides. +func nullMux(t *testing.T, _ string) (http.Handler, func() map[string]int32) { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":null}`)) + }), func() map[string]int32 { return map[string]int32{} } +} + +func cachePurgeSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := cachePurgeMux(t, rec) + return h, func() map[string]int32 { return map[string]int32{"PurgePageCache": hits()} } +} + +func envvarSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := envvarMutationMux(t, rec) + return h, func() map[string]int32 { + add, del := hits() + return map[string]int32{"AddEnvironmentVariable": add, "DeleteEnvironmentVariable": del} + } +} + +func phpmyadminSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := phpmyadminMux(t, rec) + return h, func() map[string]int32 { + en, st, gn := hits() + return map[string]int32{"EnablePhpMyAdmin": en, "PhpMyAdminStatus": st, "GeneratePhpMyAdminAccess": gn} + } +} + +func importSQLSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := importSQLMux(t, rec) + return h, func() map[string]int32 { return map[string]int32{"StartImport": hits()} } +} + +func importMediaSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := importMediaMux(t, rec) + return h, func() map[string]int32 { + start, abort := hits() + return map[string]int32{"StartMediaImport": start, "AbortMediaImport": abort} + } +} + +func syncSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := syncMux(t, rec) + return h, func() map[string]int32 { + start, progress := hits() + return map[string]int32{"SyncEnvironment": start, "SyncProgress": progress} + } +} + +func m7cSurfaceMux(t *testing.T, rec string) (http.Handler, func() map[string]int32) { + h, hits := m7cMux(t, rec) + return h, func() map[string]int32 { + return map[string]int32{ + "TriggerDatabaseBackup": hits("TriggerDatabaseBackup"), + "BackupDBCopy": hits("BackupDBCopy"), + "StartCustomDeploy": hits("StartCustomDeploy"), + } + } +} + +// surfaceCase is one convertible scenario. +type surfaceCase struct { + // mux builds the API mock. Required. + mux surfaceMuxFactory + + // wantHits, when set, asserts a mutation count against EACH binary's own + // counters. This is what stops a "the mutation must not fire" scenario from + // degrading into "the mutation did not fire in total". + wantHits map[string]int32 +} + +// surfaceDifferentialScenarios is the vetted allowlist. Ordering is by family +// so the reason a family is present or absent stays legible. +var surfaceDifferentialScenarios = map[string]surfaceCase{ + // --- cache purge-url ------------------------------------------------- + // Single mutation, no polling, no prompt. The mock had to learn Node's + // operation name (PurgePageCacheMutation) before these could run. + "cache-purge-url-single": {mux: cachePurgeSurfaceMux, wantHits: map[string]int32{"PurgePageCache": 1}}, + "cache-purge-url-multi": {mux: cachePurgeSurfaceMux, wantHits: map[string]int32{"PurgePageCache": 1}}, + "cache-purge-url-empty": {mux: cachePurgeSurfaceMux, wantHits: map[string]int32{"PurgePageCache": 0}}, + // --from-file must WIN over the positional URLs on both sides; the + // recording's urls.txt and the ignored positional make that observable in + // the diffed stdout. + "cache-purge-url-from-file": {mux: cachePurgeSurfaceMux, wantHits: map[string]int32{"PurgePageCache": 1}}, + + // --- config envvar set / delete -------------------------------------- + // The mutating half of the envvar surface. M5 covers only the reads. + "envvar-set-baseline": {mux: envvarSurfaceMux, wantHits: map[string]int32{"AddEnvironmentVariable": 1}}, + "envvar-set-invalid-name": {mux: envvarSurfaceMux, wantHits: map[string]int32{"AddEnvironmentVariable": 0}}, + "envvar-set-newrelic-blocked": {mux: envvarSurfaceMux, wantHits: map[string]int32{"AddEnvironmentVariable": 0}}, + "envvar-set-prod-cancel": {mux: envvarSurfaceMux, wantHits: map[string]int32{"AddEnvironmentVariable": 0}}, + "envvar-set-prod-confirm-skipped": {mux: envvarSurfaceMux, wantHits: map[string]int32{"AddEnvironmentVariable": 1}}, + "envvar-delete-baseline": {mux: envvarSurfaceMux, wantHits: map[string]int32{"DeleteEnvironmentVariable": 1}}, + "envvar-delete-prod-cancel": {mux: envvarSurfaceMux, wantHits: map[string]int32{"DeleteEnvironmentVariable": 0}}, + "envvar-delete-typed-mismatch": {mux: envvarSurfaceMux, wantHits: map[string]int32{"DeleteEnvironmentVariable": 0}}, + + // --- db phpmyadmin ---------------------------------------------------- + // Both recordings report status "running" on the first poll, which is what + // keeps Node off its unconditional 30-second post-enable sleep + // (src/commands/phpmyadmin.ts:220). + "phpmyadmin-print": {mux: phpmyadminSurfaceMux, wantHits: map[string]int32{"EnablePhpMyAdmin": 0, "GeneratePhpMyAdminAccess": 1}}, + "phpmyadmin-silent": {mux: phpmyadminSurfaceMux, wantHits: map[string]int32{"EnablePhpMyAdmin": 0, "GeneratePhpMyAdminAccess": 1}}, + // The error recording has no status.json, so status is unknown on both + // sides and both take the enable branch, where the mutation errors. Node + // exits on the GraphQL error before it reaches the poll and the 30s sleep, + // which is what keeps this scenario cheap. + "phpmyadmin-error": {mux: phpmyadminSurfaceMux, wantHits: map[string]int32{"EnablePhpMyAdmin": 1, "GeneratePhpMyAdminAccess": 0}}, + + // --- import validate-sql --------------------------------------------- + // Local-only static validator: no app context, no API call from either CLI. + // The cheapest honest differential in the repo. + "import-validate-sql-clean": {mux: nullMux}, + "import-validate-sql-multisite-warn": {mux: nullMux}, + "import-validate-sql-dangerous-stmt": {mux: nullMux}, + + // --- import validate-files ------------------------------------------- + // Local traversal, but Node fetches MediaImportConfig for the limits + // (allowed extensions, size cap, filename length), so it needs the mock. + "import-validate-files-clean": {mux: importMediaSurfaceMux}, + "import-validate-files-not-dir": {mux: importMediaSurfaceMux}, + + // --- app deploy ------------------------------------------------------- + // validate is local-only; missing-token fails its gate before any call. + "app-deploy-validate-clean": {mux: nullMux}, + "app-deploy-validate-missing-themes": {mux: nullMux}, + "app-deploy-missing-token": {mux: nullMux}, + + // --- import sql gates ------------------------------------------------- + // Every one aborts at a client-side gate, so no import is ever started; + // the counter assertion is per-binary, which is what makes it meaningful + // when two CLIs share one server. + "import-sql-bad-extension": {mux: importSQLSurfaceMux, wantHits: map[string]int32{"StartImport": 0}}, + "import-sql-invalid-md5": {mux: importSQLSurfaceMux, wantHits: map[string]int32{"StartImport": 0}}, + "import-sql-in-progress": {mux: importSQLSurfaceMux, wantHits: map[string]int32{"StartImport": 0}}, + + // --- backup db -------------------------------------------------------- + // The already-in-progress recording reports inProgressLock on the first + // status and clears it on the second, so Node spends one 1s interval here. + "backup-db-already-in-progress": {mux: m7cSurfaceMux, wantHits: map[string]int32{"TriggerDatabaseBackup": 0}}, + + // --- export sql ------------------------------------------------------- + // Fails the flag-exclusivity check before any network call. + "export-sql-config-conflict": {mux: m7cSurfaceMux, wantHits: map[string]int32{"BackupDBCopy": 0}}, +} + +// surfaceDifferentialExclusions records scenarios deliberately NOT run as +// differentials, each with the reason it cannot be one. +// +// Keep the reason SPECIFIC and CHECKED. "Flaky" is not a reason. "Node rejects +// --skip-confirmation on this command, so the comparison would be a working +// command against a usage error" is. Every reason below was observed by running +// the real Node binary, not inferred from src/. +var surfaceDifferentialExclusions = map[string]string{ + // ---- covered elsewhere ---- + "whoami-baseline": "already a real Node-vs-Go differential — TestWhoamiBaselineParity " + + "(whoami_scenario_test.go) owns it, with its own single-fixture handler.", + "version-smoke": "not a Node-vs-Go scenario at all: it self-diffs vip-next against a " + + "second vip-next built with different version metadata, to prove the harness " + + "pipeline works. Node prints `4.1.0` and vip-next prints " + + "`vip-next <ver> (commit <sha>)` — an intentional format change (register §3), " + + "so a cross-CLI diff here would assert nothing the register does not already state.", + + // ---- argv Node does not accept ---- + "import-media-invalid-archive": "argv is not valid for Node: `import media --skip-confirmation` " + + "exits 1 with `The option \"skip-confirmation\" is unknown` (observed). The flag is " + + "vip-next-only surface (register §5), so the differential would compare a working " + + "command against a usage error.", + "import-media-url-completed": "same as import-media-invalid-archive — carries " + + "--skip-confirmation, which Node rejects at parse time.", + + // ---- poll cost / non-terminating fixtures ---- + "import-media-status-completed": "two blockers at once. (1) Same `App` operationName " + + "collision as import-sql-status-no-job — Node's media poll is `query App($appId: Int, " + + "$envId: Int)` at src/lib/media-import/status.ts:31. (2) Node's progress table repaints " + + "every 200ms (src/lib/media-import/progress.ts:7) with no non-TTY guard, so stdout is a " + + "stream of cursor escapes whose COUNT depends on wall-clock timing; a byte diff against " + + "that measures scheduler luck, not parity.", + "import-media-status-failed": "same `App` collision and same non-deterministic 200ms " + + "progress repaint as import-media-status-completed.", + "import-sql-status-completed": "same `App` collision (src/lib/site-import/status.ts:28), " + + "plus a repainting progress table on a 5s poll — the frame count, and therefore stdout, " + + "depends on how long the mock takes to answer.", + "import-sql-status-no-job": "operationName collision the mux cannot resolve: Node's status " + + "poll is ALSO called `App` (src/lib/site-import/status.ts:28, `query App($appId: Int, " + + "$envId: Int)`) — the same name as the wrapper's app resolution (src/lib/api/app.ts:46). " + + "Routing on operationName alone sends both to resolve-app.json. Disambiguating needs " + + "variable-shape routing ($name/$id vs $appId+$envId) plus a Node-shaped " + + "data.app.environments[].jobs fixture. Tractable, but it is new mux machinery rather " + + "than a fixture tweak, so it is deferred rather than guessed at.", + + // ---- prompts that cannot be driven identically ---- + "import-sql-noninteractive-abort": "the scenario turns on VIP_NON_INTERACTIVE=1, which Node " + + "does not consult outside src/lib/rechallenge/flow.ts and " + + "src/lib/defensive-mode/cli-helpers.ts. Node instead renders an enquirer prompt whose " + + "promise never settles on a non-TTY stdin, drains the event loop and exits 0 without " + + "running the handler. Comparing that against vip-next's explicit non-interactive " + + "refusal compares two different questions.", + "import-media-abort-noninteractive": "same VIP_NON_INTERACTIVE=1 mismatch as " + + "import-sql-noninteractive-abort; Node has no non-interactive mode for this prompt.", + "import-sql-validation-failure": "Node runs its SQL validation INSIDE the interactive " + + "import flow, after the enquirer prompt that never settles on a non-TTY stdin, so the " + + "validation report is never reached. The equivalent Node-reachable assertion is " + + "import-validate-sql-dangerous-stmt, which IS converted.", + + // ---- help renderers ---- + "backup-db-help": "help text is produced by a different renderer on each side (commander " + + "vs cobra) and diverges completely by construction — different usage line, different " + + "option table, Node appends an Examples section vip-next has no equivalent for. " + + "See the report: this is a real, UNRECORDED divergence (register §3 documents the " + + "--version format change but says nothing about --help), and converting all four " + + "*-help scenarios would add four copies of one finding. Left unconverted; the " + + "divergence is reported with exact output instead.", + "export-sql-help": "same renderer divergence as backup-db-help.", + "import-sql-help": "same renderer divergence as backup-db-help.", + "import-media-help": "same renderer divergence as backup-db-help.", + + // ---- mutating flows whose fixtures do not describe one world ---- + "backup-db-completed": "the recording drives backup-status-N.json off a per-handler " + + "sequence counter, and the two CLIs issue a different NUMBER of status queries for " + + "the same flow (Node re-fetches once more after the poll, src/commands/backup-db.ts:218). " + + "Sequenced fixtures indexed by call count therefore hand the two CLIs different " + + "worlds. Needs state-based fixtures (respond by job state, not by call number) before " + + "it can be a differential.", + "export-sql-completed": "same call-count-indexed sequencing problem as backup-db-completed, " + + "and Node additionally requires job metadata (`backupId` matching latestBackup.id, and " + + "`bytesWritten`) that the recording does not carry — absent, Node throws " + + "`Export job metadata does not contain bytesWritten` (src/commands/export-sql.ts:412).", + "app-deploy-completed": "Node's custom-deploy upload path signs and PUTs the archive to a " + + "presigned URL derived from the response; the mock's presign endpoint is written for " + + "vip-next's request shape only. Converting needs Node's upload contract mirrored " + + "first — a real piece of work, not a fixture tweak.", + "sync-baseline": "argv is not valid for Node: `vip @app.env sync --skip-confirmation` exits " + + "1 with `The option \"skip-confirmation\" is unknown` (observed). Node's confirmation " + + "gate is `requireConfirm`, which registers `--force` and nothing else " + + "(src/bin/vip-sync.js:25); vip-next accepts both spellings (register 2.7). Converting " + + "means a Node-valid argv, and then Node's ~1s repainting progress renderer " + + "(src/bin/vip-sync.js:114) still has to be dealt with.", + "sync-already-syncing": "same `--skip-confirmation` rejection as sync-baseline.", + + // ---- transport the harness cannot stand up ---- + "wp-ssh-happy": "`vip wp` needs a live SSH/WebSocket transport, not GraphQL. The scenario " + + "stands up an in-process echo server that speaks vip-next's exec preamble; Node would " + + "need the same server to speak ITS protocol, which is a separate fake to build. Out of " + + "reach for this pass.", + "wp-websocket-redirect": "asserts vip-next's deliberate 'requires the Node CLI' redirect " + + "(register §3). Node has no such redirect — it just runs the command — so the two " + + "implementations are not answering the same question and a diff is meaningless.", + "wp-nodejs-rejected": "needs the same wp transport fake as wp-ssh-happy before Node can " + + "reach the environment-type gate.", + "wp-production-confirm-decline": "combines the wp transport problem with the " + + "VIP_NON_INTERACTIVE mismatch (Node does not consult it for this prompt).", + "wp-help": "`vip help wp` is vip-next surface: `help` as a subcommand is listed in " + + "register §4 as new in vip-next. Node has no `vip help <cmd>` form.", + + // ---- rechallenge ---- + "defensive-mode-enable-with-rechallenge": "trunk DOES have src/lib/rechallenge/, " + + "src/lib/defensive-mode/ and four vip-defensive-mode-* bins, so the review's " + + "'Go-only' claim was wrong and a differential is feasible IN PRINCIPLE. It is not " + + "feasible from this scenario: the argv carries --skip-confirmation and " + + "--non-interactive (both vip-next-only, register §4) and VIP_RECHALLENGE_WAIT, and " + + "Node's step-up flow opens a verification URL and polls a session the mock does not " + + "implement for Node's contract. Converting it means building a Node-shaped " + + "rechallenge mock first — worth doing, out of scope here.", +} + +// TestSurfaceDifferentialParity runs every convertible non-M5 scenario as a +// real Node-vs-Go differential. +func TestSurfaceDifferentialParity(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("TestSurfaceDifferentialParity — the non-M5 Node-vs-Go differentials", skip)) + } + + names := make([]string, 0, len(surfaceDifferentialScenarios)) + for name := range surfaceDifferentialScenarios { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + c := surfaceDifferentialScenarios[name] + // No t.Parallel: subtests swap the shared server's handler. + t.Run(name, func(t *testing.T) { + path := "../../testdata/parity/" + name + ".yaml" + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + scenario.Env = rig.scenarioEnv(scenario) + + nodeRes, nodeHits := rig.runSide(t, scenario, rig.nodeBin, c.mux) + goRes, goHits := rig.runSide(t, scenario, rig.goBin, c.mux) + + // Wire-level assertions run per binary, against that binary's own + // counters, so "must not fire" cannot be satisfied by the other CLI + // having behaved. + assertHits(t, "node", c.wantHits, nodeHits) + assertHits(t, "go", c.wantHits, goHits) + + d, err := Diff(scenario, nodeRes, goRes) + if err != nil { + t.Fatalf("Diff(%s): %v", name, err) + } + if d.Equal { + if scenario.ExpectedDrift != nil { + t.Errorf("%s carries expected_drift (%s) but Node and Go now agree. "+ + "Delete the annotation.", name, scenario.ExpectedDrift.Reason) + } + return + } + + report := "Node (a) vs Go (b) diverge (argv: %v):\n %s\n %s\n %s" + if scenario.ExpectedDrift != nil { + fmt.Fprintf(os.Stderr, "parity: BLESSED DRIFT %s — %s\n", + name, strings.Join(strings.Fields(scenario.ExpectedDrift.Reason), " ")) + t.Logf("BLESSED DRIFT — "+scenario.ExpectedDrift.Reason+"\n"+report, + scenario.Argv, d.ExitCodeDelta, d.StdoutDelta, d.StderrDelta) + return + } + t.Errorf(report, scenario.Argv, d.ExitCodeDelta, d.StdoutDelta, d.StderrDelta) + }) + } +} + +// runSide installs a fresh mux, runs one binary against it, and restores the +// previous handler. See surfaceMuxFactory for why the mux is per-side. +func (r *differentialRig) runSide( + t *testing.T, s *Scenario, bin string, mk surfaceMuxFactory, +) (*RunResult, map[string]int32) { + t.Helper() + h, hits := mk(t, s.Recording) + previous := r.handler.Load() + r.handler.Store(&h) + defer r.handler.Store(previous) + + res, err := Run(RunSpec{Binary: bin, Argv: s.Argv, Env: FixtureEnv(s.Env)}) + if err != nil { + t.Fatalf("run %s (%s): %v", bin, s.Name, err) + } + return res, hits() +} + +func assertHits(t *testing.T, side string, want, got map[string]int32) { + t.Helper() + ops := make([]string, 0, len(want)) + for op := range want { + ops = append(ops, op) + } + sort.Strings(ops) + for _, op := range ops { + if got[op] != want[op] { + t.Errorf("%s: %s fired %d times, want %d", side, op, got[op], want[op]) + } + } +} + +// TestEverySurfaceScenarioIsClassified is the anti-drift guard for everything +// M5's own guard does not cover. Together the two account for every YAML in +// testdata/parity/. +// +// Without it, adding a scenario produces one more Go-vs-mock test that looks +// like parity coverage and is not — which is precisely the state this file was +// written to end. +func TestEverySurfaceScenarioIsClassified(t *testing.T) { + entries, err := filepath.Glob("../../testdata/parity/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + var seen int + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if isM5Scenario(base) { + continue // m5_differential_test.go owns these + } + seen++ + if _, ok := surfaceDifferentialScenarios[base]; ok { + continue + } + if reason, ok := surfaceDifferentialExclusions[base]; ok { + if strings.TrimSpace(reason) == "" { + t.Errorf("%s is excluded from the differential with an empty reason", base) + } + continue + } + t.Errorf("scenario %s runs against the mock only. Add it to "+ + "surfaceDifferentialScenarios, or to surfaceDifferentialExclusions with a "+ + "reason saying why Node cannot run it.", base) + } + if seen == 0 { + t.Fatal("no non-M5 scenarios found — testdata may have moved") + } +} + +// TestSurfaceClassificationIsExclusive keeps the two maps from disagreeing: a +// scenario that is both converted and excluded would silently run while +// carrying a written reason why it cannot, which is worse than either. +func TestSurfaceClassificationIsExclusive(t *testing.T) { + for name := range surfaceDifferentialScenarios { + if reason, ok := surfaceDifferentialExclusions[name]; ok { + t.Errorf("%s is in BOTH surfaceDifferentialScenarios and "+ + "surfaceDifferentialExclusions (%q)", name, reason) + } + } + // An exclusion for a scenario that no longer exists is a stale reason + // nobody will ever re-examine. + for name := range surfaceDifferentialExclusions { + if _, err := os.Stat("../../testdata/parity/" + name + ".yaml"); err != nil { + t.Errorf("surfaceDifferentialExclusions names %s, which has no YAML", name) + } + } +} diff --git a/internal/parity/sync_scenario_test.go b/internal/parity/sync_scenario_test.go new file mode 100644 index 000000000..8d7ad16e3 --- /dev/null +++ b/internal/parity/sync_scenario_test.go @@ -0,0 +1,172 @@ +//go:build parity + +package parity + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +// syncMux dispatches GraphQL requests for the M6 sync scenarios. +// +// Operation → file mapping: +// +// ResolveAppByName / ResolveAppByID -> resolve-app.json +// SyncEnvironment -> sync-start.json +// SyncProgress (1st hit) -> sync-status-1.json +// SyncProgress (subsequent) -> sync-status-2.json +// +// The progress counter is exposed so tests can assert the poll loop +// actually ticked. Missing fixtures fall back to {"data":null}. +func syncMux(t *testing.T, recordingDir string) (http.Handler, func() (start, progress int32)) { + t.Helper() + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + maybeRead := func(name string) []byte { + b, err := os.ReadFile(base + name) + if err != nil { + return nil + } + return b + } + + resolveAppBody := maybeRead("resolve-app.json") + syncStartBody := maybeRead("sync-start.json") + status1Body := maybeRead("sync-status-1.json") + status2Body := maybeRead("sync-status-2.json") + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var startHits, progressHits int32 + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + // `App` is Node's app resolution (src/lib/api/app.ts:46,69). + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`), + strings.Contains(s, `"operationName":"App"`): + serve(w, resolveAppBody) + // Node names the same mutation SyncEnvironmentMutation + // (src/bin/vip-sync.js:40); Go drops the suffix. + case strings.Contains(s, `"operationName":"SyncEnvironment"`), + strings.Contains(s, `"operationName":"SyncEnvironmentMutation"`): + atomic.AddInt32(&startHits, 1) + serve(w, syncStartBody) + case strings.Contains(s, `"operationName":"SyncProgress"`): + i := atomic.AddInt32(&progressHits, 1) + if i == 1 { + serve(w, status1Body) + } else { + serve(w, status2Body) + } + default: + serve(w, nil) + } + }) + hits := func() (int32, int32) { + return atomic.LoadInt32(&startHits), atomic.LoadInt32(&progressHits) + } + return mux, hits +} + +var syncPrefixes = []string{ + "sync-", +} + +func isSyncScenario(name string) bool { + for _, p := range syncPrefixes { + if strings.HasPrefix(name, p) { + return true + } + } + return false +} + +// TestM6SyncScenarios discovers every YAML matching sync-* and runs it +// against syncMux. Both current scenarios should eventually reach the +// success status payload and exit 0. +func TestM6SyncScenarios(t *testing.T) { + yamlDir := "../../testdata/parity" + entries, err := filepath.Glob(yamlDir + "/*.yaml") + if err != nil { + t.Fatalf("glob yaml: %v", err) + } + + var scenarios []string + for _, path := range entries { + base := strings.TrimSuffix(filepath.Base(path), ".yaml") + if isSyncScenario(base) { + scenarios = append(scenarios, path) + } + } + if len(scenarios) == 0 { + t.Fatal("no M6 sync scenarios found — testdata may have moved") + } + + goBin := buildVipNextWithVersion(t, "test", "test") + + for _, path := range scenarios { + scenarioName := strings.TrimSuffix(filepath.Base(path), ".yaml") + + t.Run(scenarioName, func(t *testing.T) { + scenario, err := LoadScenario(path) + if err != nil { + t.Fatalf("LoadScenario(%s): %v", path, err) + } + + if scenario.ExpectedDrift != nil { + t.Skipf("expected drift (%s); skipping assertion for %s", scenario.ExpectedDrift.Reason, scenarioName) + return + } + + mux, hits := syncMux(t, scenario.Recording) + srv := httptest.NewServer(mux) + defer srv.Close() + + if scenario.Env == nil { + scenario.Env = map[string]string{} + } + scenario.Env["API_HOST"] = srv.URL + scenario.Env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: scenario.Argv, + Env: FixtureEnv(scenario.Env), + }) + if err != nil { + t.Fatalf("Run(%s): %v", scenarioName, err) + } + + if res.ExitCode != scenario.Expect.ExitCode { + t.Errorf("%s: exit code = %d, want %d\n stderr: %s\n stdout: %s", + scenarioName, res.ExitCode, scenario.Expect.ExitCode, + res.Stderr, res.Stdout) + } + + startHits, progressHits := hits() + // Both scenarios fire the mutation exactly once. + if startHits != 1 { + t.Errorf("%s: SyncEnvironment hits = %d, want 1", scenarioName, startHits) + } + // Polling must have ticked at least once to reach a terminal state. + if progressHits < 1 { + t.Errorf("%s: SyncProgress hits = %d, want >= 1", scenarioName, progressHits) + } + }) + } +} diff --git a/internal/parity/vendored_src_test.go b/internal/parity/vendored_src_test.go new file mode 100644 index 000000000..77c3750c5 --- /dev/null +++ b/internal/parity/vendored_src_test.go @@ -0,0 +1,84 @@ +//go:build parity + +package parity + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// src/, __tests__/ and friends are a VENDORED MIRROR of Automattic/vip. They +// are the reference the entire parity effort is measured against, so they must +// stay byte-identical to upstream. +// +// That invariant was broken once, silently, and it cost a lot: four lines +// implementing VIP_TOKEN_OVERRIDE had been hand-injected into +// src/lib/token.ts, plus a matching test in __tests__/lib/token.js, so the +// harness could authenticate the Node binary. The variable has never existed +// upstream (`git log --all -S VIP_TOKEN_OVERRIDE` on Automattic/vip returns +// zero commits). Because the doctored files were then treated as +// authoritative, the parity review recorded a divergence that did not exist +// (register item 2.15) and a slice "fixed" Go to match a fiction. +// +// A full upstream diff needs network access and the sibling checkout, so this +// test does not attempt one. It pins the specific, cheap invariant that would +// have caught the actual incident: no credential escape hatch anywhere in the +// vendored Node trees. If a future harness needs to authenticate Node, seed a +// real keychain entry (keychain.go) — do not edit the mirror. +func TestVendoredNodeSourceHasNoCredentialEscapeHatch(t *testing.T) { + // Env vars that would let a caller inject an identity without the OS + // credential store. Node's own supported hatch, WPVIP_DEPLOY_TOKEN, is + // deliberately absent from this list: it is real upstream surface. + forbidden := []string{ + "VIP_TOKEN_OVERRIDE", + "VIP_ACCESS_TOKEN", + } + + // Every vendored tree, not just src/: the historical injection touched + // src/lib/token.ts AND __tests__/lib/token.js, so a src-only walk would + // have caught only half of it. + roots := []string{"src", "__tests__", "__fixtures__", "helpers", "test-utils"} + + var hits []string + for _, r := range roots { + root := filepath.Join("..", "..", r) + if _, err := os.Stat(root); err != nil { + continue // an absent vendored tree is not this test's problem + } + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + switch filepath.Ext(path) { + case ".js", ".ts", ".mjs", ".cjs": + default: + return nil + } + body, readErr := os.ReadFile(path) // #nosec G304 -- fixed vendored tree + if readErr != nil { + return readErr + } + for _, name := range forbidden { + if strings.Contains(string(body), name) { + hits = append(hits, path+" contains "+name) + } + } + return nil + }) + if err != nil { + t.Fatalf("walking vendored %s/: %v", r, err) + } + } + + for _, h := range hits { + t.Errorf("vendored Node source carries a credential escape hatch: %s\n"+ + "These trees mirror upstream Automattic/vip and must not be edited. "+ + "To authenticate the Node binary in a test, seed a real keychain "+ + "entry — see keychain.go.", h) + } +} diff --git a/internal/parity/whoami_scenario_test.go b/internal/parity/whoami_scenario_test.go new file mode 100644 index 000000000..9a0761501 --- /dev/null +++ b/internal/parity/whoami_scenario_test.go @@ -0,0 +1,69 @@ +//go:build parity + +package parity + +import ( + "net/http" + "os" + "strconv" + "testing" +) + +// TestWhoamiBaselineParity runs the real Node CLI and diffs it against +// vip-next. It was for a long time the ONLY such test; TestM5DifferentialParity +// (m5_differential_test.go) now covers the read-only M5 command surface the +// same way. Everything else still compares vip-next against a mock, i.e. tests +// Go behaviour, not parity. +// +// `make test-parity-unit` points NODE_VIP_BIN at ./dist/bin/vip.js so this +// actually executes; when the Node CLI genuinely cannot run it skips with a +// banner naming what is missing, never silently. CI additionally runs +// `make require-node-vip-bin`, which fails the job outright rather than letting +// a skipped differential pass for a green one. +func TestWhoamiBaselineParity(t *testing.T) { + rig, skip := differentialAvailable(t) + if skip != "" { + t.Skip(LoudSkip("TestWhoamiBaselineParity — the whoami Node-vs-Go differential", skip)) + } + + resp, err := os.ReadFile("../../testdata/parity/recordings/whoami-baseline/me-response.json") + if err != nil { + t.Fatalf("read recording: %v", err) + } + rig.serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(resp) + })) + + scenario, err := LoadScenario("../../testdata/parity/whoami-baseline.yaml") + if err != nil { + t.Fatalf("LoadScenario: %v", err) + } + scenario.Env = rig.scenarioEnv(scenario) + + // If the shared keychain seed had silently done nothing, Node would fall + // through to its login banner and this would fail loudly — there is no path + // where a broken seed looks like a pass. + t.Logf("test token id=%s", strconv.FormatInt(fixtureTokenUserID, 10)) + + d, err := CompareBinaries(scenario, rig.nodeBin, rig.goBin) + if err != nil { + t.Fatalf("CompareBinaries: %v", err) + } + if !d.Equal { + t.Errorf("Node vs Go diverge:\n ExitCodeDelta: %s\n StdoutDelta: %s\n StderrDelta: %s", + d.ExitCodeDelta, d.StdoutDelta, d.StderrDelta) + } +} + +// makeTestToken is the scenario-level alias for FixtureToken — the same +// deterministic credential ScenarioEnv pins into the base environment. It +// exists so a mock-only scenario can state its auth requirement explicitly at +// the call site rather than relying on the base env. Differential scenarios use +// the rig's token instead, so that Node's seeded credential and Go's env +// override are the same string. +func makeTestToken(t *testing.T) string { + t.Helper() + t.Logf("test token id=%s", strconv.FormatInt(fixtureTokenUserID, 10)) + return FixtureToken() +} diff --git a/internal/parity/wp_scenario_test.go b/internal/parity/wp_scenario_test.go new file mode 100644 index 000000000..74b77f9ce --- /dev/null +++ b/internal/parity/wp_scenario_test.go @@ -0,0 +1,428 @@ +//go:build parity + +package parity + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +// wpMux builds a GraphQL handler for the vip wp scenarios. +// +// recordingDir selects the resolve-app.json fixture (per-scenario dir). +// wpEnvInfoBody is the JSON to return for WPEnvInfo (built per-scenario). +// triggerBody is the JSON to return for TriggerWPCLICommand (built per-scenario, +// or nil if the mutation must not fire — the counter still increments so the +// test can assert 0 hits). +func wpMux( + t *testing.T, + recordingDir string, + wpEnvInfoBody []byte, + triggerBody []byte, +) (http.Handler, func() int32) { + t.Helper() + shared := "../../testdata/parity/recordings/m7c-shared/" + base := "../../testdata/parity/recordings/" + recordingDir + "/" + + read := func(name string) []byte { + if b, err := os.ReadFile(base + name); err == nil { + return b + } + if b, err := os.ReadFile(shared + name); err == nil { + return b + } + return nil + } + + nullBody := []byte(`{"data":null}`) + serve := func(w http.ResponseWriter, body []byte) { + if body == nil { + body = nullBody + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + } + + var triggerHits int32 + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s := string(body) + switch { + case strings.Contains(s, `"operationName":"ResolveAppByName"`), + strings.Contains(s, `"operationName":"ResolveAppByID"`): + serve(w, read("resolve-app.json")) + case strings.Contains(s, `"operationName":"WPEnvInfo"`): + serve(w, wpEnvInfoBody) + case strings.Contains(s, `"operationName":"TriggerWPCLICommand"`): + atomic.AddInt32(&triggerHits, 1) + serve(w, triggerBody) + default: + serve(w, nil) + } + }) + + return handler, func() int32 { return atomic.LoadInt32(&triggerHits) } +} + +// wpEnvInfoJSON builds the WPEnvInfo response body. +func wpEnvInfoJSON(typeID int64, wpcliStrategy, envType string) []byte { + strategyField := "null" + if wpcliStrategy != "" { + strategyField = `"` + wpcliStrategy + `"` + } + return []byte(fmt.Sprintf( + `{"data":{"app":{"id":42,"name":"parityapp","typeId":%d,"environments":[{"id":7,"appId":42,"type":%q,"name":"develop","wpcliStrategy":%s,"primaryDomain":{"name":"d.example"}}]}}}`, + typeID, envType, strategyField, + )) +} + +// wpEnvInfoJSONProd builds the WPEnvInfo response body for production envs. +func wpEnvInfoJSONProd(typeID int64, wpcliStrategy string) []byte { + strategyField := "null" + if wpcliStrategy != "" { + strategyField = `"` + wpcliStrategy + `"` + } + return []byte(fmt.Sprintf( + `{"data":{"app":{"id":42,"name":"parityapp","typeId":%d,"environments":[{"id":1,"appId":42,"type":"production","name":"production","wpcliStrategy":%s,"primaryDomain":{"name":"p.example"}}]}}}`, + typeID, strategyField, + )) +} + +// testClientKeyPEM generates a fresh ed25519 private key as an OpenSSH PEM +// (the format ssh.ParsePrivateKey accepts). +func wpTestClientKeyPEM(t *testing.T) string { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ed25519 key: %v", err) + } + block, err := gossh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal private key: %v", err) + } + return string(pem.EncodeToMemory(block)) +} + +// startWPEchoSSHServer starts an in-process SSH echo server (copied/adapted +// from internal/wpssh/wpssh_test.go). On each "exec" request it writes the +// command string back to stdout and exits 0. +func startWPEchoSSHServer(t *testing.T) (host, port string) { + t.Helper() + + _, hostPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + hostSigner, err := gossh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatalf("new host signer: %v", err) + } + + cfg := &gossh.ServerConfig{ + NoClientAuth: true, // accept any client key + } + cfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + addr := ln.Addr().String() + host, port, err = net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host/port: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go wpHandleSSHConn(conn, cfg) + } + }() + + return host, port +} + +func wpHandleSSHConn(conn net.Conn, cfg *gossh.ServerConfig) { + srvConn, chans, reqs, err := gossh.NewServerConn(conn, cfg) + if err != nil { + return + } + defer srvConn.Close() + go gossh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(gossh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + return + } + go wpHandleSession(ch, requests) + } +} + +type wpExecPayload struct { + Command string +} + +func wpHandleSession(ch gossh.Channel, requests <-chan *gossh.Request) { + defer ch.Close() + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + _ = req.Reply(false, nil) + } + continue + } + var payload wpExecPayload + if err := gossh.Unmarshal(req.Payload, &payload); err != nil { + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + if req.WantReply { + _ = req.Reply(true, nil) + } + // Echo the command string to stdout so the caller can assert the preamble. + _, _ = fmt.Fprint(ch, payload.Command) + exitMsg := gossh.Marshal(struct{ Code uint32 }{0}) + _, _ = ch.SendRequest("exit-status", false, exitMsg) + return + } +} + +// triggerSSHResponse builds the TriggerWPCLICommand response JSON that points +// the SSH auth at the in-process echo server. All string values are formatted +// with %q so newlines and special characters in the PEM are properly escaped. +func triggerSSHResponse(host, port, privateKeyPEM string) []byte { + return []byte(fmt.Sprintf( + `{"data":{"triggerWPCLICommandOnAppEnvironment":{"inputToken":"tok-parity","command":{"guid":"parity-guid-001"},"sshAuthentication":{"host":%q,"port":%q,"username":"wpuser","privateKey":%q,"passphrase":""}}}}`, + host, port, privateKeyPEM, + )) +} + +// baseWPEnv returns a common env map for all wp parity scenarios. +func baseWPEnv() map[string]string { + return map[string]string{ + "DO_NOT_TRACK": "1", + "NODE_ENV": "test", + "NO_COLOR": "1", + } +} + +// TestWPScenarios runs the five vip wp parity scenarios. +func TestWPScenarios(t *testing.T) { + goBin := buildVipNextWithVersion(t, "test", "test") + + // ── 1. wp-help ──────────────────────────────────────────────────────────── + // `vip help wp` bypasses auth and prints the wp command's help text. + // Note: `vip wp --help` does NOT work because DisableFlagParsing passes + // --help through as a raw WP-CLI arg and the appctx middleware fires first. + // `vip help wp` routes through cobra's built-in help path which does NOT + // invoke the command's RunE and therefore bypasses the appctx middleware. + t.Run("wp-help", func(t *testing.T) { + env := baseWPEnv() + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"help", "wp"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "wp") { + t.Errorf("help output missing 'wp':\n%s", combined) + } + }) + + // ── 2. wp-nodejs-rejected ───────────────────────────────────────────────── + // WPEnvInfo returns typeId:3 (Node.js) → exit 1, error message, no trigger. + t.Run("wp-nodejs-rejected", func(t *testing.T) { + envInfoBody := wpEnvInfoJSON(3, "ssh", "develop") + handler, triggerHits := wpMux(t, "wp-nodejs-rejected", envInfoBody, nil) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := baseWPEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "wp", "user", "list"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 1 { + t.Errorf("exit=%d, want 1\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "WP-CLI commands are not supported on Node.js environments.") { + t.Errorf("missing nodejs-rejection message:\n%s", combined) + } + if got := triggerHits(); got != 0 { + t.Errorf("TriggerWPCLICommand hits = %d, want 0", got) + } + }) + + // ── 3. wp-production-confirm-decline ───────────────────────────────────── + // Production env + VIP_NON_INTERACTIVE=1 (no --yes) → confirm declines → + // "Command cancelled" → exit 0. Trigger must NOT fire. + t.Run("wp-production-confirm-decline", func(t *testing.T) { + // Production WPEnvInfo: typeId 2 (WordPress), ssh strategy. + envInfoBody := wpEnvInfoJSONProd(2, "ssh") + handler, triggerHits := wpMux(t, "wp-production-confirm-decline", envInfoBody, nil) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := baseWPEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + env["VIP_NON_INTERACTIVE"] = "1" + + // @parityapp.production → resolve-app.json in wp-production-confirm-decline + // has type:production. No --yes flag passed. + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.production", "wp", "user", "list"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + if !strings.Contains(combined, "Command cancelled") { + t.Errorf("missing 'Command cancelled':\n%s", combined) + } + if got := triggerHits(); got != 0 { + t.Errorf("TriggerWPCLICommand hits = %d, want 0", got) + } + }) + + // ── 4. wp-ssh-happy ─────────────────────────────────────────────────────── + // Develop env, ssh strategy. The mutation returns ssh auth pointing at the + // in-process echo server. Exit 0; output contains GUID= from the preamble. + t.Run("wp-ssh-happy", func(t *testing.T) { + sshHost, sshPort := startWPEchoSSHServer(t) + + // Generate a client key that the server will accept (NoClientAuth=true). + clientKeyPEM := wpTestClientKeyPEM(t) + + triggerResp := triggerSSHResponse(sshHost, sshPort, clientKeyPEM) + envInfoBody := wpEnvInfoJSON(2, "ssh", "develop") + handler, triggerHits := wpMux(t, "wp-ssh-happy", envInfoBody, triggerResp) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := baseWPEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + // Use --yes to skip any production confirm (develop doesn't need it, + // but included for clarity). Pass a simple wp subcommand. + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "--yes", "wp", "user", "list"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("exit=%d, want 0\n stderr: %s\n stdout: %s", + res.ExitCode, res.Stderr, res.Stdout) + } + combined := res.Stdout + res.Stderr + // The echo server writes the exec preamble to stdout, which includes GUID=. + if !strings.Contains(combined, "GUID=parity-guid-001") { + t.Errorf("missing GUID= in exec preamble:\n%s", combined) + } + if got := triggerHits(); got != 1 { + t.Errorf("TriggerWPCLICommand hits = %d, want 1", got) + } + }) + + // ── 5. wp-socketio-reaches-trigger ─────────────────────────────────────── + // Develop env, websocket strategy (WP2). The binary enters the wpstream + // path and fires TriggerWPCLICommand (triggerHits == 1). The GraphQL mux + // has NO /socket.io/ endpoint, so wpstream.Run → Dial will fail and the + // process exits non-zero. We verify: + // • exit code != 0 (connection failure — exact code is env-dependent) + // • combined output does NOT contain "requires the Node CLI" (redirect gone) + // • trigger fired exactly once (we entered the wpstream path) + // + // Full-binary socket.io e2e is handled by internal/wpstream/e2e_test.go + // (build tag: wpstream_e2e), which validates the wpstream stack end-to-end + // against a real socket.io server via the Run API. The cmd wiring is + // unit-tested in cmd/vip-next/commands/wp_test.go. + t.Run("wp-socketio-reaches-trigger", func(t *testing.T) { + envInfoBody := wpEnvInfoJSON(2, "websocket", "develop") + // triggerWebsocketResponse returns a valid TriggerWPCLICommand payload + // with inputToken + command.guid but no SSH auth (websocket envs). + triggerBody := []byte(`{"data":{"triggerWPCLICommandOnAppEnvironment":{"inputToken":"tok-ws","command":{"guid":"ws-guid-001"},"sshAuthentication":null}}}`) + handler, triggerHits := wpMux(t, "wp-socketio-reaches-trigger", envInfoBody, triggerBody) + srv := httptest.NewServer(handler) + defer srv.Close() + + env := baseWPEnv() + env["API_HOST"] = srv.URL + env["VIP_TOKEN_OVERRIDE"] = makeTestToken(t) + + res, err := Run(RunSpec{ + Binary: goBin, + Argv: []string{"@parityapp.develop", "--yes", "wp", "option", "get", "home"}, + Env: FixtureEnv(env), + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + // The binary must fail (no real socket.io server), exit != 0. + if res.ExitCode == 0 { + t.Errorf("exit=0, want non-zero (wpstream dial should fail)\n stderr: %s\n stdout: %s", + res.Stderr, res.Stdout) + } + // The WP1 redirect must be gone. + combined := res.Stdout + res.Stderr + if strings.Contains(combined, "requires the Node CLI") { + t.Errorf("unexpected 'requires the Node CLI' in output (WP1 redirect should be gone):\n%s", combined) + } + // Trigger must have fired once — we entered the wpstream path. + if got := triggerHits(); got != 1 { + t.Errorf("TriggerWPCLICommand hits = %d, want 1", got) + } + }) +} diff --git a/internal/phpmyadmin/client.go b/internal/phpmyadmin/client.go new file mode 100644 index 000000000..e659bf5fc --- /dev/null +++ b/internal/phpmyadmin/client.go @@ -0,0 +1,221 @@ +// Package phpmyadmin implements the enable + poll + generate flow for +// `vip db phpmyadmin`. The Node implementation in src/commands/phpmyadmin.ts +// treats this as one user-visible operation but internally fires up to three +// GraphQL operations, gated by maybeEnablePhpMyAdmin (phpmyadmin.ts:213): +// +// private async maybeEnablePhpMyAdmin(): Promise< void > { +// const status = await this.getStatus(); +// if ( ! [ 'running', 'enabled' ].includes( status ) ) { +// await enablePhpMyAdmin( this.env.id as number ); +// await pollUntil( this.getStatus.bind( this ), 1000, ( sts: string ) => sts === 'running' ); +// // Additional 30s for LB routing to be updated +// await setTimeout( 30_000 ); +// } +// } +// +// So: +// +// 1. PhpMyAdminStatus query — always. When it already reads "running" or +// "enabled" the whole enable branch is skipped: no mutation, no poll, no +// load-balancer wait. +// 2. EnablePhpMyAdmin mutation — only when the environment is not already up. +// 3. PhpMyAdminStatus polled at a 1s tick until status == "running", under +// pollUntil's default 6h ceiling (utils.ts:18) — NOT a 60s one; a cold +// environment can legitimately take many minutes. +// 4. A 30s settle for LB routing, then GeneratePhpMyAdminAccess. +package phpmyadmin + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/poll" +) + +// Node's timings (src/commands/phpmyadmin.ts:217,220 + src/lib/utils.ts:18). +const ( + // DefaultPollInterval is pollUntil's 1000ms tick. + DefaultPollInterval = 1 * time.Second + // DefaultPollTimeout is pollUntil's default ceiling: Node passes no + // timeout here, so the poll may legitimately run for six hours. + DefaultPollTimeout = poll.DefaultTimeout + // DefaultPostEnableWait is the "Additional 30s for LB routing to be + // updated" settle after a cold enable. + DefaultPostEnableWait = 30 * time.Second +) + +// RunOpts configures Run. Stderr is the progress sink. The durations are +// exposed so callers (and tests) can shorten the waits; leave them zero to +// pick up Node's values. +type RunOpts struct { + Silent bool + Stderr io.Writer + PollInterval time.Duration + PollTimeout time.Duration + // PostEnableWait is the LB settle after enabling. A negative value + // skips it; zero means DefaultPostEnableWait. + PostEnableWait time.Duration + + // sleep is the clock seam for PostEnableWait. Production leaves it nil + // (time.Sleep); tests inject a recorder so the 30s settle costs nothing. + sleep func(time.Duration) +} + +// resolveRunOpts fills in Node's defaults for anything the caller left zero. +// It is a plain function so the resolved ceiling can be asserted directly — +// proving the poll really runs with the 6h value without a 6h test. +func resolveRunOpts(o RunOpts) RunOpts { + if o.Stderr == nil { + o.Stderr = io.Discard + } + if o.PollInterval == 0 { + o.PollInterval = DefaultPollInterval + } + if o.PollTimeout == 0 { + o.PollTimeout = DefaultPollTimeout + } + if o.PostEnableWait == 0 { + o.PostEnableWait = DefaultPostEnableWait + } + if o.sleep == nil { + o.sleep = time.Sleep + } + return o +} + +// Result is what Run returns on success. +type Result struct { + URL string +} + +const ( + permissionErrorMessage = "You do not have sufficient permission to access phpMyAdmin for this environment." + enableErrorMessage = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." +) + +type userError struct { + message string + cause error +} + +func (e *userError) Error() string { return e.message } +func (e *userError) Unwrap() error { return e.cause } + +func enableFailure(err error) error { + if hasGraphQLErrorMessage(err, "Unauthorized") { + return &userError{message: permissionErrorMessage, cause: err} + } + return &userError{message: enableErrorMessage, cause: err} +} + +func generateFailure(err error) error { + return &userError{message: "Failed to generate phpMyAdmin URL: " + err.Error(), cause: err} +} + +func hasGraphQLErrorMessage(err error, want string) bool { + var list gqlerror.List + if errors.As(err, &list) { + for _, item := range list { + if item != nil && item.Message == want { + return true + } + } + } + var single *gqlerror.Error + return errors.As(err, &single) && single != nil && single.Message == want +} + +// Run executes the flow. Returns the generated access URL on success, or a +// wrapped error on any step's failure. +func Run(ctx context.Context, c graphql.Client, appID, envID int64, opts RunOpts) (*Result, error) { + opts = resolveRunOpts(opts) + + getStatus := func(ctx context.Context) (string, error) { + statusResp, err := gql.PhpMyAdminStatus(ctx, c, appID, envID) + if err != nil { + return "", err + } + return readPhpMyAdminStatus(statusResp), nil + } + + // Node's progress tracker marks the ENABLE step running before + // maybeEnablePhpMyAdmin, whether or not the mutation ends up firing. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Enabling phpMyAdmin for this environment...") + } + + // 1. Status first. This is the short-circuit Go was missing: without it + // every single invocation fired an extra enable mutation. + status, err := getStatus(ctx) + if err != nil { + return nil, enableFailure(err) + } + + if status != "running" && status != "enabled" { + // 2. Enable. + enableInput := &gql.EnablePhpMyAdminInput{EnvironmentId: envID} + enableResp, err := gql.EnablePhpMyAdmin(ctx, c, enableInput) + if err != nil { + return nil, enableFailure(err) + } + if enableResp == nil || enableResp.EnablePHPMyAdmin == nil || + enableResp.EnablePHPMyAdmin.Success == nil || !*enableResp.EnablePHPMyAdmin.Success { + return nil, enableFailure(errors.New("phpMyAdmin enablement did not succeed")) + } + + // 3. Poll status until "running", under the 6h ceiling. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Waiting for phpMyAdmin to be ready...") + } + last, perr := poll.Until(ctx, getStatus, opts.PollInterval, + func(s string) bool { return s == "running" }, opts.PollTimeout) + if perr != nil { + return nil, enableFailure(fmt.Errorf("poll phpMyAdmin status (last status %q): %w", last, perr)) + } + + // 4. LB settle. + if opts.PostEnableWait > 0 { + opts.sleep(opts.PostEnableWait) + } + } + + // 5. Generate access URL. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Generating phpMyAdmin access link...") + } + genInput := &gql.GeneratePhpMyAdminAccessInput{EnvironmentId: envID} + genResp, err := gql.GeneratePhpMyAdminAccess(ctx, c, genInput) + if err != nil { + return nil, generateFailure(err) + } + if genResp == nil || genResp.GeneratePHPMyAdminAccess == nil || + genResp.GeneratePHPMyAdminAccess.Url == nil || *genResp.GeneratePHPMyAdminAccess.Url == "" { + return nil, generateFailure(errors.New("phpMyAdmin access response missing URL")) + } + return &Result{URL: *genResp.GeneratePHPMyAdminAccess.Url}, nil +} + +// readPhpMyAdminStatus pulls resp.App.Environments[0].PhpMyAdminStatus.Status +// in a nil-safe way. Genqlient generates pointers all the way down for +// optional fields, so we have to walk carefully. +func readPhpMyAdminStatus(resp *gql.PhpMyAdminStatusResponse) string { + if resp == nil || resp.App == nil { + return "" + } + envs := resp.App.Environments + if len(envs) == 0 || envs[0] == nil { + return "" + } + pma := envs[0].PhpMyAdminStatus + if pma == nil || pma.Status == nil { + return "" + } + return *pma.Status +} diff --git a/internal/phpmyadmin/client_test.go b/internal/phpmyadmin/client_test.go new file mode 100644 index 000000000..58a5519b3 --- /dev/null +++ b/internal/phpmyadmin/client_test.go @@ -0,0 +1,418 @@ +package phpmyadmin + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" +) + +// fakeServer dispatches on the GraphQL operationName in the request body. +// Each handler can be set per test; nil handlers default to a generic 200 +// with `{"data":null}` which would tell us via assertion that the test +// forgot to wire that op. +type fakeServer struct { + enable func(w http.ResponseWriter, r *http.Request) + status func(w http.ResponseWriter, r *http.Request) + generate func(w http.ResponseWriter, r *http.Request) + + enableHits int32 + statusHits int32 + generateHits int32 +} + +func (f *fakeServer) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + s := string(body) + switch { + case strings.Contains(s, `"operationName":"EnablePhpMyAdmin"`): + atomic.AddInt32(&f.enableHits, 1) + if f.enable != nil { + f.enable(w, r) + return + } + case strings.Contains(s, `"operationName":"PhpMyAdminStatus"`): + atomic.AddInt32(&f.statusHits, 1) + if f.status != nil { + f.status(w, r) + return + } + case strings.Contains(s, `"operationName":"GeneratePhpMyAdminAccess"`): + atomic.AddInt32(&f.generateHits, 1) + if f.generate != nil { + f.generate(w, r) + return + } + } + // Default: respond with an empty data payload so unhandled ops fail + // downstream assertion rather than hang. + _, _ = w.Write([]byte(`{"data":null}`)) +} + +func newClient(t *testing.T, srv *httptest.Server) graphql.Client { + t.Helper() + return graphql.NewClient(srv.URL, srv.Client()) +} + +// TestRunHappyPath: status is already "running", so Node's +// maybeEnablePhpMyAdmin (phpmyadmin.ts:213-222) short-circuits — NO enable +// mutation, NO poll loop, NO post-enable wait — and we go straight to +// generate. +// +// const status = await this.getStatus(); +// if ( ! [ 'running', 'enabled' ].includes( status ) ) { … } +// +// Go used to fire the enable mutation unconditionally on every invocation. +func TestRunHappyPath(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/abc"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + waits := 0 + var stderr bytes.Buffer + res, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: &stderr, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + PostEnableWait: time.Hour, // would hang the test if it were honoured + sleep: func(time.Duration) { waits++ }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.URL != "https://pma.example/abc" { + t.Errorf("URL = %q, want https://pma.example/abc", res.URL) + } + if fs.enableHits != 0 { + t.Errorf("enable hits = %d, want 0: phpMyAdmin is already running", fs.enableHits) + } + if fs.statusHits != 1 || fs.generateHits != 1 { + t.Errorf("status/generate hits = %d/%d, want 1/1", fs.statusHits, fs.generateHits) + } + if waits != 0 { + t.Errorf("post-enable wait ran %d times, want 0 (nothing was enabled)", waits) + } + // Progress lines must hit stderr by default. + if !strings.Contains(stderr.String(), "phpMyAdmin") { + t.Errorf("stderr missing progress; got=%q", stderr.String()) + } +} + +// TestRunSkipsEnableWhenStatusIsEnabled: "enabled" is the second value in +// Node's short-circuit list, and it skips the poll loop too — an env that +// reports "enabled" (never "running") must NOT wedge for 6 hours. +func TestRunSkipsEnableWhenStatusIsEnabled(t *testing.T) { + fs := &fakeServer{ + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"enabled"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/en"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + done := make(chan error, 1) + go func() { + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + PostEnableWait: 0, + sleep: func(time.Duration) {}, + }) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned on an 'enabled' environment") + } + if fs.enableHits != 0 { + t.Errorf("enable hits = %d, want 0 for status 'enabled'", fs.enableHits) + } + if fs.statusHits != 1 { + t.Errorf("status hits = %d, want 1: 'enabled' must not enter the poll loop", fs.statusHits) + } +} + +// TestRunWaitsForLoadBalancerAfterEnabling ports the last line of +// maybeEnablePhpMyAdmin: `await setTimeout( 30_000 )` — "Additional 30s for +// LB routing to be updated" (phpmyadmin.ts:219-220). It runs ONLY on the +// branch that actually enabled. +func TestRunWaitsForLoadBalancerAfterEnabling(t *testing.T) { + statusCalls := int32(0) + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + if atomic.AddInt32(&statusCalls, 1) == 1 { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"stopped"}}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/lb"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + var slept []time.Duration + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + sleep: func(d time.Duration) { slept = append(slept, d) }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if fs.enableHits != 1 { + t.Errorf("enable hits = %d, want 1 (status was 'stopped')", fs.enableHits) + } + if len(slept) != 1 || slept[0] != DefaultPostEnableWait { + t.Errorf("post-enable waits = %v, want [%v]", slept, DefaultPostEnableWait) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling: Node's poll here inherits the +// pollUntil default (phpmyadmin.ts:217 passes no timeout), so the ceiling is +// 6 hours. Go capped it at 60 seconds, aborting slow-but-healthy enables. +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } + if DefaultPollInterval != time.Second { + t.Errorf("DefaultPollInterval = %v, want 1s (phpmyadmin.ts:217)", DefaultPollInterval) + } + if DefaultPostEnableWait != 30*time.Second { + t.Errorf("DefaultPostEnableWait = %v, want 30s (phpmyadmin.ts:220)", DefaultPostEnableWait) + } +} + +// TestRunUsesDefaultCeilingWhenUnset closes the gap between "the constant is +// 6h" and "the loop actually runs with 6h": Run resolves a zero PollTimeout +// through resolveRunOpts, which is the value the poll loop is handed. +func TestRunUsesDefaultCeilingWhenUnset(t *testing.T) { + got := resolveRunOpts(RunOpts{}) + if got.PollTimeout != DefaultPollTimeout { + t.Errorf("resolved PollTimeout = %v, want %v", got.PollTimeout, DefaultPollTimeout) + } + if got.PollInterval != DefaultPollInterval { + t.Errorf("resolved PollInterval = %v, want %v", got.PollInterval, DefaultPollInterval) + } + if got.PostEnableWait != DefaultPostEnableWait { + t.Errorf("resolved PostEnableWait = %v, want %v", got.PostEnableWait, DefaultPostEnableWait) + } + // Explicit values survive resolution (that is what makes the ceiling + // testable without a six-hour test). + explicit := resolveRunOpts(RunOpts{PollTimeout: time.Minute, PollInterval: time.Second, PostEnableWait: -1}) + if explicit.PollTimeout != time.Minute { + t.Errorf("explicit PollTimeout was overwritten: %v", explicit.PollTimeout) + } + if explicit.PostEnableWait != -1 { + t.Errorf("explicit PostEnableWait was overwritten: %v", explicit.PostEnableWait) + } +} + +// TestRunPolling: first status "pending" then "running" — must complete +// after one poll iteration. +func TestRunPolling(t *testing.T) { + statusCalls := int32(0) + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&statusCalls, 1) + if n == 1 { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"pending"}}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/xyz"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + res, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + sleep: func(time.Duration) {}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.URL != "https://pma.example/xyz" { + t.Errorf("URL = %q, want https://pma.example/xyz", res.URL) + } + if statusCalls < 2 { + t.Errorf("status calls = %d, want >= 2 (polling kicked in)", statusCalls) + } +} + +// TestRunSilentSuppressesStderr confirms Silent skips the progress lines. +func TestRunSilentSuppressesStderr(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/q"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + var stderr bytes.Buffer + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Silent: true, + Stderr: &stderr, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if stderr.Len() != 0 { + t.Errorf("silent mode wrote to stderr: %q", stderr.String()) + } +} + +// TestRunEnableUnauthorized maps the backend detail to Node's actionable +// permission message while preserving a non-zero result. +func TestRunEnableUnauthorized(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"Unauthorized"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + const want = "You do not have sufficient permission to access phpMyAdmin for this environment." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestRunEnableFailureUsesStableSupportMessage(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"backend exploded"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + const want = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +// TestRunPollTimeout: status never reaches "running" — must error after +// PollTimeout elapses. +func TestRunPollTimeout(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"pending"}}]}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + const want = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +// TestRunGenerateFailure: enable+poll succeed but generate errors — +// surface as error. +func TestRunGenerateFailure(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"boom"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.HasPrefix(err.Error(), "Failed to generate phpMyAdmin URL: ") { + t.Errorf("error doesn't use the stable URL-generation prefix: %v", err) + } +} diff --git a/internal/poll/poll.go b/internal/poll/poll.go new file mode 100644 index 000000000..eaf937c48 --- /dev/null +++ b/internal/poll/poll.go @@ -0,0 +1,85 @@ +// Package poll ports Node's pollUntil helper (src/lib/utils.ts:9-35) — the +// shared ceiling every long-running VIP poll loop is supposed to sit under. +// +// Node: +// +// export class PollingTimeoutError extends Error {} +// +// export async function pollUntil< T >( +// fn: () => Promise< T >, +// interval: number, +// isDone: ( v: T ) => boolean, +// timeoutMs: number = 6 * 60 * 60 * 1000 // Default to 6 hours +// ) { +// const startTime = Date.now(); +// while ( Date.now() - startTime < timeoutMs ) { +// const result = await fn(); +// if ( isDone( result ) ) { return result; } +// await setTimeout( interval ); +// } +// throw new PollingTimeoutError( 'Polling timed out' ); +// } +// +// Two shape details are load-bearing and deliberately reproduced: +// +// 1. The deadline is evaluated at the TOP of the loop, before `fn` runs, so +// a non-positive ceiling never calls fn at all. +// 2. The sleep happens only after a not-done result, so the terminal check +// is never delayed by one interval. +// +// The one addition over Node is context cancellation: Node has no ctx, but a +// Go poll loop that ignores it cannot be interrupted. +package poll + +import ( + "context" + "errors" + "time" +) + +// DefaultTimeout is Node's pollUntil ceiling (utils.ts:18): 6 hours. Callers +// that pass a zero timeout to Until get this. +const DefaultTimeout = 6 * time.Hour + +// ErrTimeout ports PollingTimeoutError (utils.ts:9,34). The message matches +// Node's exactly because several callers surface it verbatim to the user. +var ErrTimeout = errors.New("Polling timed out") + +// Until calls fn every interval until isDone accepts its result, giving up +// with ErrTimeout once timeout has elapsed. A zero (or negative) interval +// polls without sleeping. timeout is taken literally — Go cannot tell an +// omitted argument from an explicit 0 the way Node's default parameter can, +// so callers resolve DefaultTimeout themselves (same pattern they already +// use for the interval). An error from fn aborts immediately and is returned +// unwrapped, matching Node: a rejection inside pollUntil propagates rather +// than being retried. +func Until[T any]( + ctx context.Context, + fn func(context.Context) (T, error), + interval time.Duration, + isDone func(T) bool, + timeout time.Duration, +) (T, error) { + var zero T + start := time.Now() + for time.Since(start) < timeout { + v, err := fn(ctx) + if err != nil { + return zero, err + } + if isDone(v) { + return v, nil + } + if interval <= 0 { + continue + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return zero, ctx.Err() + case <-timer.C: + } + } + return zero, ErrTimeout +} diff --git a/internal/poll/poll_test.go b/internal/poll/poll_test.go new file mode 100644 index 000000000..857025db4 --- /dev/null +++ b/internal/poll/poll_test.go @@ -0,0 +1,136 @@ +package poll + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestDefaultTimeoutIsNodesSixHourCeiling pins the ceiling value itself +// (src/lib/utils.ts:18 — `timeoutMs: number = 6 * 60 * 60 * 1000`). +func TestDefaultTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultTimeout != 6*time.Hour { + t.Errorf("DefaultTimeout = %v, want 6h (utils.ts:18)", DefaultTimeout) + } +} + +// TestUntilReturnsResultWhenDone is the happy path: fn is retried until +// isDone accepts the value, and that value is returned. +func TestUntilReturnsResultWhenDone(t *testing.T) { + calls := 0 + got, err := Until(context.Background(), + func(context.Context) (string, error) { + calls++ + if calls < 3 { + return "pending", nil + } + return "done", nil + }, + time.Millisecond, + func(v string) bool { return v == "done" }, + time.Minute, + ) + if err != nil { + t.Fatalf("Until: %v", err) + } + if got != "done" { + t.Errorf("result = %q, want %q", got, "done") + } + if calls != 3 { + t.Errorf("fn calls = %d, want 3", calls) + } +} + +// TestUntilStopsAtCeiling is the regression test for the unbounded poll +// loops. fn NEVER reports done; the loop must still terminate on its own +// once the ceiling elapses, and it must terminate by returning ErrTimeout +// rather than by being cancelled from outside. +// +// Before the fix there was no ceiling at all, so this test hangs forever +// (the harness below turns that into a failure instead of a wedged run). +func TestUntilStopsAtCeiling(t *testing.T) { + calls := 0 + type result struct { + err error + } + done := make(chan result, 1) + start := time.Now() + go func() { + _, err := Until(context.Background(), + func(context.Context) (string, error) { calls++; return "pending", nil }, + 5*time.Millisecond, + func(string) bool { return false }, + 60*time.Millisecond, + ) + done <- result{err} + }() + + select { + case r := <-done: + if !errors.Is(r.err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", r.err) + } + if r.err.Error() != "Polling timed out" { + t.Errorf("err.Error() = %q, want %q (utils.ts:34)", r.err.Error(), "Polling timed out") + } + if elapsed := time.Since(start); elapsed < 60*time.Millisecond { + t.Errorf("returned after %v, want >= the 60ms ceiling", elapsed) + } + if calls == 0 { + t.Error("fn was never called") + } + case <-time.After(5 * time.Second): + t.Fatal("Until never returned: the poll loop is unbounded") + } +} + +// TestUntilChecksDeadlineBeforeCallingFn matches Node's loop shape: the +// `while ( Date.now() - startTime < timeoutMs )` guard is evaluated BEFORE +// the first `await fn()`, so a non-positive ceiling never calls fn at all. +func TestUntilChecksDeadlineBeforeCallingFn(t *testing.T) { + calls := 0 + _, err := Until(context.Background(), + func(context.Context) (string, error) { calls++; return "done", nil }, + time.Millisecond, + func(string) bool { return true }, + 0, + ) + if !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", err) + } + if calls != 0 { + t.Errorf("fn calls = %d, want 0 (Node checks the deadline first)", calls) + } +} + +// TestUntilPropagatesFnError: a failing fn aborts the poll instead of being +// retried, matching a rejected promise inside Node's pollUntil. +func TestUntilPropagatesFnError(t *testing.T) { + sentinel := errors.New("boom") + _, err := Until(context.Background(), + func(context.Context) (string, error) { return "", sentinel }, + time.Millisecond, + func(string) bool { return true }, + time.Minute, + ) + if !errors.Is(err, sentinel) { + t.Errorf("err = %v, want the fn error", err) + } +} + +// TestUntilHonoursContextCancellation — Go-only addition (Node's pollUntil +// has no cancellation): a cancelled context aborts the wait immediately. +func TestUntilHonoursContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(10 * time.Millisecond); cancel() }() + _, err := Until(ctx, + func(context.Context) (string, error) { return "pending", nil }, + 50*time.Millisecond, + func(string) bool { return false }, + time.Hour, + ) + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } +} diff --git a/internal/polling/polling.go b/internal/polling/polling.go new file mode 100644 index 000000000..44df93864 --- /dev/null +++ b/internal/polling/polling.go @@ -0,0 +1,97 @@ +package polling + +import ( + "context" + "fmt" + "os" + "time" +) + +// Opts configures Loop behavior. +type Opts struct { + InitialLimit int // limit used on the first fetch + FollowLimit int // limit used on subsequent fetches (Node: LIMIT_MAX) + DefaultInterval time.Duration // sleep when server doesn't hint a delay + ServerHintMin time.Duration // floor for server-hinted delay + ServerHintMax time.Duration // ceiling for delay (errors also capped here) + ErrorBackoffStep time.Duration // added per consecutive error +} + +// Page is what a fetch returns. +type Page struct { + Render func() error + NextCursor *string + PollingDelaySecs int +} + +// Fetch is the caller-supplied page fetcher. +type Fetch func(ctx context.Context, after *string, limit int) (Page, error) + +// Loop fetches pages forever (or until ctx cancellation). First-call error +// returns immediately (Node parity). Subsequent errors back off and continue. +func Loop(ctx context.Context, opts Opts, fetch Fetch) error { + if opts.DefaultInterval == 0 { + opts.DefaultInterval = 30 * time.Second + } + if opts.ServerHintMin == 0 { + opts.ServerHintMin = 5 * time.Second + } + if opts.ServerHintMax == 0 { + opts.ServerHintMax = 5 * time.Minute + } + if opts.ErrorBackoffStep == 0 { + opts.ErrorBackoffStep = 30 * time.Second + } + if opts.FollowLimit == 0 { + opts.FollowLimit = opts.InitialLimit + } + + var ( + after *string + firstCall = true + delay = opts.DefaultInterval + ) + + for { + limit := opts.InitialLimit + if !firstCall { + limit = opts.FollowLimit + } + page, err := fetch(ctx, after, limit) + if err != nil { + if firstCall { + return err + } + delay += opts.ErrorBackoffStep + if delay > opts.ServerHintMax { + delay = opts.ServerHintMax + } + fmt.Fprintf(os.Stderr, "Error: Failed to fetch. Trying again in %d seconds.\n", int(delay.Seconds())) + } else { + if page.Render != nil { + if rerr := page.Render(); rerr != nil { + return rerr + } + } + after = page.NextCursor + firstCall = false + if page.PollingDelaySecs > 0 { + delay = time.Duration(page.PollingDelaySecs) * time.Second + } else { + delay = opts.DefaultInterval + } + if delay < opts.ServerHintMin { + delay = opts.ServerHintMin + } + if delay > opts.ServerHintMax { + delay = opts.ServerHintMax + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } +} diff --git a/internal/polling/polling_test.go b/internal/polling/polling_test.go new file mode 100644 index 000000000..1b5e5651a --- /dev/null +++ b/internal/polling/polling_test.go @@ -0,0 +1,59 @@ +package polling + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestLoopFirstCallErrorExits(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 30 * time.Second} + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + return Page{}, errors.New("network down") + } + err := Loop(context.Background(), opts, fetch) + if err == nil { + t.Error("first-call error must propagate (Node parity)") + } +} + +func TestLoopUsesInitialLimitThenFollowLimit(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 1 * time.Millisecond, ServerHintMin: 1 * time.Millisecond, ServerHintMax: 1 * time.Millisecond} + var seenLimits []int + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + seenLimits = append(seenLimits, limit) + if len(seenLimits) >= 3 { + return Page{}, context.Canceled + } + return Page{ + Render: func() error { return nil }, + NextCursor: nil, + PollingDelaySecs: 0, + }, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _ = Loop(ctx, opts, fetch) + if len(seenLimits) < 2 { + t.Fatalf("expected at least 2 calls; got %d", len(seenLimits)) + } + if seenLimits[0] != 100 { + t.Errorf("first call limit = %d, want 100", seenLimits[0]) + } + if seenLimits[1] != 5000 { + t.Errorf("second call limit = %d, want 5000 (FollowLimit)", seenLimits[1]) + } +} + +func TestLoopRespectsContextCancellation(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 50 * time.Millisecond} + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + return Page{Render: func() error { return nil }}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := Loop(ctx, opts, fetch); err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Errorf("expected ctx cancellation error, got %v", err) + } +} diff --git a/internal/rechallenge/browser.go b/internal/rechallenge/browser.go new file mode 100644 index 000000000..36b5eec00 --- /dev/null +++ b/internal/rechallenge/browser.go @@ -0,0 +1,16 @@ +package rechallenge + +import ( + "log/slog" + + "github.com/pkg/browser" +) + +// OpenBrowser tries to open url in the user's default browser. Errors are +// swallowed (logged at debug level) — they're not actionable for the CLI. +// Mirrors src/lib/rechallenge/open-browser.ts. +func OpenBrowser(url string) { + if err := browser.OpenURL(url); err != nil { + slog.Debug("rechallenge.OpenBrowser failed", "err", err, "url", url) + } +} diff --git a/internal/rechallenge/client.go b/internal/rechallenge/client.go new file mode 100644 index 000000000..a976a26c9 --- /dev/null +++ b/internal/rechallenge/client.go @@ -0,0 +1,198 @@ +package rechallenge + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Client speaks the Parker REST protocol. APIHost should NOT include a trailing +// slash; paths supplied to its methods come from extensions.rechallenge and +// already start with "/". +type Client struct { + APIHost string + BearerToken string + HTTP *http.Client +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + // A bare &http.Client{} would inherit http.DefaultTransport's proxy + // policy, which is the inverse of Node's. Step-up requests carry the + // bearer token and mint an elevated one. See internal/httpproxy. + return httpproxy.ClientWithTimeout(30 * time.Second) +} + +type CreateSessionInput struct { + Path string + RequestedOperation string +} + +func (c *Client) CreateSession(in CreateSessionInput) (*Session, error) { + body, err := json.Marshal(map[string]string{ + "clientType": ClientType, + "requestedOperation": in.RequestedOperation, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", c.absoluteURL(in.Path), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", randomUUID()) + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.RequestedOperation) + } + var s Session + if err := decodeJSON(resp.Body, &s); err != nil { + return nil, err + } + return &s, nil +} + +type GetSessionStatusInput struct { + Template string + ChallengeID string + Scope string +} + +func (c *Client) GetSessionStatus(in GetSessionStatusInput) (*SessionStatus, error) { + path := fillTemplate(in.Template, in.ChallengeID) + req, err := http.NewRequest("GET", c.absoluteURL(path), nil) + if err != nil { + return nil, err + } + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.Scope) + } + var ss SessionStatus + if err := decodeJSON(resp.Body, &ss); err != nil { + return nil, err + } + return &ss, nil +} + +type ExchangeInput struct { + Template string + ChallengeID string + Scope string +} + +func (c *Client) Exchange(in ExchangeInput) (*ExchangeResponse, error) { + path := fillTemplate(in.Template, in.ChallengeID) + req, err := http.NewRequest("POST", c.absoluteURL(path), nil) + if err != nil { + return nil, err + } + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.Scope) + } + var er ExchangeResponse + if err := decodeJSON(resp.Body, &er); err != nil { + return nil, err + } + return &er, nil +} + +func (c *Client) attachAuthorization(req *http.Request) error { + if c.BearerToken == "" { + return nil + } + apiURL, err := url.Parse(c.APIHost) + if err != nil { + return fmt.Errorf("parse rechallenge API host: %w", err) + } + if !strings.EqualFold(req.URL.Scheme, apiURL.Scheme) || !strings.EqualFold(req.URL.Host, apiURL.Host) { + return fmt.Errorf("refusing cross-origin rechallenge request to %s://%s", req.URL.Scheme, req.URL.Host) + } + req.Header.Set("Authorization", "Bearer "+c.BearerToken) + return nil +} + +// absoluteURL combines APIHost with the path UNLESS the path is already absolute. +// Parker templates may be returned as relative paths or full URLs. Authenticated +// requests accept full URLs only when attachAuthorization confirms they are on +// the same origin as APIHost. +func (c *Client) absoluteURL(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + return c.APIHost + path +} + +func fillTemplate(template, challengeID string) string { + return strings.ReplaceAll(template, "{challengeId}", url.PathEscape(challengeID)) +} + +func is2xx(code int) bool { return code >= 200 && code < 300 } + +// httpErrorFromResponse turns a non-2xx Parker response into an error carrying +// the server's own text — that text is the whole diagnosis when step-up fails. +// +// It is redacted at birth rather than at the point of display: the error is +// surfaced to the user, written to CI logs, and shipped to telemetry by +// main.go's exit hook, and Parker echoes request context (including the +// Authorization header we just sent) into some error payloads. Redacting here +// means no future consumer has to remember to. +func (c *Client) httpErrorFromResponse(resp *http.Response, scope string) error { + body, _ := io.ReadAll(resp.Body) + return NewHttpError(resp.StatusCode, RedactSecrets(string(body), c.BearerToken), scope) +} + +func decodeJSON(r io.Reader, v any) error { + body, err := io.ReadAll(r) + if err != nil { + return err + } + return json.Unmarshal(body, v) +} + +func randomUUID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" + } + // RFC 4122 v4 layout: set version + variant nibbles. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + h := hex.EncodeToString(b) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] +} diff --git a/internal/rechallenge/client_test.go b/internal/rechallenge/client_test.go new file mode 100644 index 000000000..9a8b99549 --- /dev/null +++ b/internal/rechallenge/client_test.go @@ -0,0 +1,169 @@ +package rechallenge + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + json "encoding/json/v2" +) + +func TestClientCreateSession(t *testing.T) { + var gotMethod, gotPath, gotIdem, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotIdem = r.Header.Get("Idempotency-Key") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"challengeId":"abc","status":"pending","verificationUrl":"https://x/v/abc","pollIntervalSeconds":2,"expiresAt":"2026-06-05T12:00:00Z"}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + s, err := c.CreateSession(CreateSessionInput{ + Path: "/p/v2/cli/sessions", + RequestedOperation: "updateDefensiveModeStatus", + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if gotMethod != "POST" { + t.Errorf("method = %q, want POST", gotMethod) + } + if gotPath != "/p/v2/cli/sessions" { + t.Errorf("path = %q", gotPath) + } + if gotIdem == "" { + t.Error("Idempotency-Key header must be set") + } + var body map[string]string + if err := json.Unmarshal([]byte(gotBody), &body); err != nil { + t.Fatalf("body parse: %v", err) + } + if body["clientType"] != "cli" { + t.Errorf("clientType = %q", body["clientType"]) + } + if body["requestedOperation"] != "updateDefensiveModeStatus" { + t.Errorf("requestedOperation = %q", body["requestedOperation"]) + } + if s.ChallengeID != "abc" || s.Status != StatusPending { + t.Errorf("session decode bad: %+v", s) + } +} + +func TestClientGetSessionStatus(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"challengeId":"abc","status":"verified","expiresAt":"2026-06-05T12:00:00Z","provider":"passkeys","pollIntervalSeconds":2}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + ss, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "abc", + Scope: "x", + }) + if err != nil { + t.Fatalf("GetSessionStatus: %v", err) + } + if gotPath != "/p/v2/cli/sessions/abc" { + t.Errorf("path = %q", gotPath) + } + if ss.Status != StatusVerified || ss.Provider != "passkeys" { + t.Errorf("status decode bad: %+v", ss) + } +} + +func TestClientGetSessionStatusURLEncodesChallengeID(t *testing.T) { + // Use RequestURI (the raw URI as sent over the wire). r.URL.Path is the + // decoded form, which can't distinguish the encoded slash from a literal one. + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.RequestURI + w.Write([]byte(`{"challengeId":"a/b","status":"pending","expiresAt":"2026-06-05T12:00:00Z","pollIntervalSeconds":2}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + _, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "a/b", + }) + if err != nil { + t.Fatalf("GetSessionStatus: %v", err) + } + if !strings.Contains(gotPath, "a%2Fb") { + t.Errorf("path %q must URL-encode challengeId", gotPath) + } +} + +func TestClientExchange(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"2026-06-05T13:00:00Z","purpose":"u"}}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + res, err := c.Exchange(ExchangeInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}/elevated-token", + ChallengeID: "abc", + }) + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if res.ElevatedToken.Token != "elev" { + t.Errorf("token = %q", res.ElevatedToken.Token) + } +} + +func TestClientHttpErrorOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + w.Write([]byte("service unavailable")) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + _, err := c.CreateSession(CreateSessionInput{ + Path: "/x", + RequestedOperation: "u", + }) + if err == nil { + t.Fatal("expected error on 503") + } + var herr *HttpError + if !errors.As(err, &herr) { + t.Fatalf("err is %T, want *HttpError", err) + } + if herr.StatusCode() != 503 { + t.Errorf("statusCode = %d, want 503", herr.StatusCode()) + } +} + +func TestClientRejectsCrossOriginRechallengeURL(t *testing.T) { + var foreignHits atomic.Int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignHits.Add(1) + w.Write([]byte(`{"challengeId":"abc","status":"pending","expiresAt":"2026-06-05T12:00:00Z","pollIntervalSeconds":2}`)) + })) + defer foreign.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer api.Close() + + c := &Client{APIHost: api.URL, BearerToken: "primary-token", HTTP: api.Client()} + _, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: foreign.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "abc", + Scope: "updateDefensiveModeStatus", + }) + if err == nil { + t.Fatal("expected cross-origin rechallenge URL to be rejected") + } + if got := foreignHits.Load(); got != 0 { + t.Errorf("foreign server hits = %d, want 0", got) + } +} diff --git a/internal/rechallenge/errors.go b/internal/rechallenge/errors.go new file mode 100644 index 000000000..896a375ff --- /dev/null +++ b/internal/rechallenge/errors.go @@ -0,0 +1,137 @@ +package rechallenge + +import "fmt" + +// Error is the base rechallenge error. Specific failure modes carry one as +// their `base` field and expose it via Unwrap so errors.As(err, &*Error{}) +// recognizes the family. Naming note: we can't embed *Error anonymously +// because the type name and the Error() method collide. +type Error struct { + msg string + scope string +} + +func (e *Error) Error() string { return e.msg } +func (e *Error) Scope() string { return e.scope } + +// UnsupportedVersionError — server requested a version this CLI doesn't speak. +type UnsupportedVersionError struct { + base Error + version string +} + +func NewUnsupportedVersionError(version, scope string) *UnsupportedVersionError { + return &UnsupportedVersionError{ + base: Error{ + msg: fmt.Sprintf( + "Server requested rechallenge version %q but this CLI only supports %s. Update vip-cli.", + version, Version, + ), + scope: scope, + }, + version: version, + } +} + +func (e *UnsupportedVersionError) Error() string { return e.base.msg } +func (e *UnsupportedVersionError) Scope() string { return e.base.scope } +func (e *UnsupportedVersionError) Version() string { return e.version } +func (e *UnsupportedVersionError) Unwrap() error { return &e.base } + +// TerminalError — session ended in a non-verified terminal state. +type TerminalError struct { + base Error + status Status +} + +func NewTerminalError(status Status, scope, detail string) *TerminalError { + msg := fmt.Sprintf("Step-up verification did not complete (status=%s)", status) + if detail != "" { + msg += ": " + detail + } + msg += "." + return &TerminalError{ + base: Error{msg: msg, scope: scope}, + status: status, + } +} + +func (e *TerminalError) Error() string { return e.base.msg } +func (e *TerminalError) Scope() string { return e.base.scope } +func (e *TerminalError) Status() Status { return e.status } +func (e *TerminalError) Unwrap() error { return &e.base } + +// InteractionRequiredError — a step-up challenge was raised in a session where +// no human can answer it. Returned INSTEAD of opening a verification session, +// because polling one to expiry is an unbounded block in exactly the context +// (CI, cron, a piped script) that can least afford it. +// +// Mirrors RechallengeInteractionRequiredError in src/lib/rechallenge/errors.ts. +// The wording differs on one point: Node offers `--rechallenge-wait` as well as +// the environment variable; vip-next has only the environment variable, because +// the flag has no cobra registration to land on and advertising it would be a +// promise the binary does not keep. +type InteractionRequiredError struct { + base Error +} + +func NewInteractionRequiredError(scope string) *InteractionRequiredError { + return &InteractionRequiredError{ + base: Error{ + msg: fmt.Sprintf( + "Step-up verification is required for %s, but this is a non-interactive session, "+ + "so the challenge cannot be approved. Re-run the command interactively, or set "+ + "%s=1 to print the verification URL and wait while you complete it on another "+ + "device. An approval completed interactively is cached, so a later "+ + "non-interactive run of the same operation reuses it until it expires.", + scope, WaitEnvVar, + ), + scope: scope, + }, + } +} + +func (e *InteractionRequiredError) Error() string { return e.base.msg } +func (e *InteractionRequiredError) Scope() string { return e.base.scope } +func (e *InteractionRequiredError) Unwrap() error { return &e.base } + +// AbortedError — user cancelled the flow (signal or interactive cancel). +type AbortedError struct { + base Error +} + +func NewAbortedError(scope string) *AbortedError { + return &AbortedError{ + base: Error{msg: "Step-up verification was cancelled.", scope: scope}, + } +} + +func (e *AbortedError) Error() string { return e.base.msg } +func (e *AbortedError) Scope() string { return e.base.scope } +func (e *AbortedError) Unwrap() error { return &e.base } + +// HttpError — Parker REST endpoint returned a non-2xx response. +type HttpError struct { + base Error + statusCode int + bodyText string +} + +func NewHttpError(statusCode int, bodyText, scope string) *HttpError { + return &HttpError{ + base: Error{ + msg: fmt.Sprintf( + "Step-up verification request failed (HTTP %d): %s", statusCode, bodyText, + ), + scope: scope, + }, + statusCode: statusCode, + bodyText: bodyText, + } +} + +func (e *HttpError) Error() string { return e.base.msg } +func (e *HttpError) Scope() string { return e.base.scope } +func (e *HttpError) StatusCode() int { return e.statusCode } +func (e *HttpError) BodyText() string { return e.bodyText } +func (e *HttpError) Unwrap() error { return &e.base } diff --git a/internal/rechallenge/errors_test.go b/internal/rechallenge/errors_test.go new file mode 100644 index 000000000..23921230e --- /dev/null +++ b/internal/rechallenge/errors_test.go @@ -0,0 +1,81 @@ +package rechallenge + +import ( + "errors" + "strings" + "testing" +) + +func TestUnsupportedVersionError(t *testing.T) { + err := NewUnsupportedVersionError("v3", "doThing") + if !strings.Contains(err.Error(), "v3") || !strings.Contains(err.Error(), "v2") { + t.Errorf("message = %q; want both v3 and v2 mentioned", err.Error()) + } + if err.Scope() != "doThing" { + t.Errorf("Scope = %q, want doThing", err.Scope()) + } + var rerr *Error + if !errors.As(err, &rerr) { + t.Error("errors.As must match base *Error") + } +} + +func TestTerminalError(t *testing.T) { + err := NewTerminalError(StatusFailed, "doThing", "user rejected") + if !strings.Contains(err.Error(), "failed") { + t.Errorf("message = %q must include status", err.Error()) + } + if !strings.Contains(err.Error(), "user rejected") { + t.Errorf("message = %q must include detail", err.Error()) + } + if err.Status() != StatusFailed { + t.Errorf("Status() = %q", err.Status()) + } +} + +func TestTerminalErrorWithoutDetail(t *testing.T) { + err := NewTerminalError(StatusExpired, "doThing", "") + if !strings.Contains(err.Error(), "expired") { + t.Errorf("message = %q must include status", err.Error()) + } + // Format is "Step-up verification did not complete (status=expired)." + // when detail is empty — no ":" should appear. + if strings.Count(err.Error(), ":") != 0 { + t.Errorf("message = %q must not include ':' when detail is empty", err.Error()) + } +} + +func TestAbortedError(t *testing.T) { + err := NewAbortedError("doThing") + if !strings.Contains(err.Error(), "cancelled") { + t.Errorf("message = %q must include 'cancelled'", err.Error()) + } +} + +func TestHttpError(t *testing.T) { + err := NewHttpError(503, "service unavailable", "doThing") + if err.StatusCode() != 503 { + t.Errorf("StatusCode = %d, want 503", err.StatusCode()) + } + if err.BodyText() != "service unavailable" { + t.Errorf("BodyText = %q", err.BodyText()) + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("message must include status code") + } +} + +func TestErrorIs(t *testing.T) { + for _, err := range []error{ + NewUnsupportedVersionError("v3", "s"), + NewTerminalError(StatusFailed, "s", ""), + NewAbortedError("s"), + NewHttpError(500, "x", "s"), + NewInteractionRequiredError("s"), + } { + var base *Error + if !errors.As(err, &base) { + t.Errorf("errors.As(*Error) failed for %T", err) + } + } +} diff --git a/internal/rechallenge/flow.go b/internal/rechallenge/flow.go new file mode 100644 index 000000000..37975f8ab --- /dev/null +++ b/internal/rechallenge/flow.go @@ -0,0 +1,224 @@ +package rechallenge + +import ( + "context" + "fmt" + "io" + "os" + "time" +) + +// Tracker is the minimal telemetry interface the runner needs. +type Tracker interface { + Track(name string, props map[string]any) +} + +// Runner orchestrates one step-up flow. All side-effect dependencies are +// injectable for testing. +type Runner struct { + Client *Client + Tracker Tracker + TokenCache *TokenCache + + // Stdout receives the user-facing verification prompt. Defaults to os.Stderr + // (Node's flow.ts uses console.warn so we mirror to stderr). + Stdout io.Writer + + // OpenURL is called to open the browser when Interactive is true. + // Defaults to OpenBrowser. + OpenURL func(url string) + + // Sleep is called between polls. ctx-aware; tests inject a no-op or + // ctx-blocking variant. + Sleep func(ctx context.Context, d time.Duration) error +} + +// MinPollInterval floors the server-supplied poll interval. A missing, zero, or +// negative pollIntervalSeconds used to produce a zero sleep, i.e. an +// unthrottled status-poll loop against Parker for the life of the session. +// Matches MIN_POLL_INTERVAL_SECONDS in src/lib/rechallenge/flow.ts. +const MinPollInterval = 2 * time.Second + +// RunInput contains everything the runner needs for one invocation. +type RunInput struct { + RequestedOperation string + Extension Extension + Interactive bool + + // Wait opts a non-interactive caller back in to polling. Without it a + // step-up challenge raised outside a TTY fails immediately rather than + // blocking on an approval nobody present can give. See + // ShouldWaitForRechallenge. + Wait bool +} + +func (r *Runner) writer() io.Writer { + if r.Stdout != nil { + return r.Stdout + } + return os.Stderr +} + +func (r *Runner) sleep(ctx context.Context, d time.Duration) error { + if r.Sleep != nil { + return r.Sleep(ctx, d) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +func (r *Runner) openURL(url string) { + if r.OpenURL != nil { + r.OpenURL(url) + return + } + OpenBrowser(url) +} + +// bearerToken returns the credential this runner authenticates with, for use as +// a redaction secret. Nil-safe: callers redact even when there is no token to +// redact, so the JWT pattern still applies. +func (r *Runner) bearerToken() string { + if r.Client == nil { + return "" + } + return r.Client.BearerToken +} + +func (r *Runner) track(name string, props map[string]any) { + if r.Tracker == nil { + return + } + r.Tracker.Track(name, props) +} + +// Run executes the step-up flow and returns the elevated token on success. +func (r *Runner) Run(ctx context.Context, in RunInput) (*ElevatedToken, error) { + scope := in.RequestedOperation + + if in.Extension.Version != Version { + return nil, NewUnsupportedVersionError(in.Extension.Version, scope) + } + + r.track("rechallenge_required", map[string]any{ + "scope": scope, + "clientType": ClientType, + }) + + // Fail before creating the session, not after. A step-up challenge in a + // non-interactive session is unsatisfiable by construction: the approval + // happens in a browser and there is nobody at one. Polling it anyway meant + // a CI job blocked for the entire verification window and then failed with + // "expired" — the worst of both, a long wait and no explanation. Minting + // the session first would also leave a challenge on the server that can + // only ever expire unused. + if !in.Interactive && !in.Wait { + r.track("rechallenge_interaction_required", map[string]any{"scope": scope}) + return nil, NewInteractionRequiredError(scope) + } + + session, err := r.Client.CreateSession(CreateSessionInput{ + Path: in.Extension.CreateSessionPath, + RequestedOperation: scope, + }) + if err != nil { + return nil, err + } + r.track("rechallenge_session_created", map[string]any{"scope": scope}) + + if in.Interactive { + r.openURL(session.VerificationURL) + fmt.Fprintf(r.writer(), + "⚠ Step-up verification required for %s.\n Opened %s\n If your browser did not open, paste the URL above. Expires at %s.\n", + scope, session.VerificationURL, session.ExpiresAt.Format(time.RFC3339), + ) + } else { + fmt.Fprintf(r.writer(), + "Step-up verification required for %s. Complete it at: %s (expires at %s).\n", + scope, session.VerificationURL, session.ExpiresAt.Format(time.RFC3339), + ) + } + + // A session with no usable expiry is not a session we can wait on: the + // old `!deadline.IsZero()` guard turned a missing or unparseable + // expiresAt into "no deadline at all", so the loop below polled forever + // with nothing able to stop it but SIGINT. Node refuses the same case + // outright (flow.ts:93). + deadline := session.ExpiresAt + if deadline.IsZero() { + return nil, NewTerminalError(StatusExpired, scope, + "server did not return a usable expiresAt for the verification session") + } + + pollInterval := time.Duration(session.PollIntervalSeconds) * time.Second + if pollInterval < MinPollInterval { + pollInterval = MinPollInterval + } + + for { + // Check context cancellation BEFORE sleeping so a pre-cancelled ctx + // returns AbortedError without polling. + if ctx.Err() != nil { + return nil, NewAbortedError(scope) + } + if err := r.sleep(ctx, pollInterval); err != nil { + return nil, NewAbortedError(scope) + } + if time.Now().After(deadline) { + return nil, NewTerminalError(StatusExpired, scope, "session window elapsed before completion") + } + + ss, err := r.Client.GetSessionStatus(GetSessionStatusInput{ + Template: in.Extension.StatusPathTemplate, + ChallengeID: session.ChallengeID, + Scope: scope, + }) + if err != nil { + return nil, err + } + + if !ss.Status.IsTerminal() { + continue + } + + if ss.Status == StatusVerified { + provider := ss.Provider + if provider == "" { + provider = "unknown" + } + r.track("rechallenge_verified", map[string]any{ + "scope": scope, + "provider": provider, + }) + exch, err := r.Client.Exchange(ExchangeInput{ + Template: in.Extension.ExchangePathTemplate, + ChallengeID: session.ChallengeID, + Scope: scope, + }) + if err != nil { + return nil, err + } + r.track("rechallenge_exchanged", map[string]any{"scope": scope}) + tok := exch.ElevatedToken + tok.HeaderName = in.Extension.ElevatedHeaderName + if r.TokenCache != nil { + _ = r.TokenCache.Set(scope, tok) + } + return &tok, nil + } + + // Non-verified terminal status. + r.track(fmt.Sprintf("rechallenge_%s", ss.Status), map[string]any{"scope": scope}) + detail := "" + if ss.StatusReason != nil { + // Server-controlled text on its way to the terminal, CI logs, and + // the telemetry exit hook. + detail = RedactSecrets(ss.StatusReason.Message, r.bearerToken()) + } + return nil, NewTerminalError(ss.Status, scope, detail) + } +} diff --git a/internal/rechallenge/flow_noninteractive_test.go b/internal/rechallenge/flow_noninteractive_test.go new file mode 100644 index 000000000..5494ba13f --- /dev/null +++ b/internal/rechallenge/flow_noninteractive_test.go @@ -0,0 +1,285 @@ +package rechallenge + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// pendingForeverServer answers createSession with a session that never leaves +// "pending" and does not expire for an hour. Any flow that decides to poll it +// runs until the watchdog fires, which is exactly the CI hang under test. +func pendingForeverServer(t *testing.T, sessionsCreated *int32) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + hour := func() string { return time.Now().Add(time.Hour).Format(time.RFC3339) } + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(sessionsCreated, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + hour() + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + hour() + `","pollIntervalSeconds":0}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func testExtension(base string) Extension { + return Extension{ + Version: Version, + CreateSessionPath: "/p/sessions", + StatusPathTemplate: base + "/p/sessions/{challengeId}", + ExchangePathTemplate: base + "/p/sessions/{challengeId}/exchange", + ElevatedHeaderName: "x-elevated-token", + } +} + +// runAsync runs the flow on a goroutine and returns a channel carrying the +// result. The caller MUST select against a watchdog: a regression here is an +// infinite poll loop, and a plain synchronous call would express it as a stuck +// CI job instead of a red build. +func runAsync(ctx context.Context, r *Runner, in RunInput) <-chan error { + done := make(chan error, 1) + go func() { _, err := r.Run(ctx, in); done <- err }() + return done +} + +// TestFlowNonInteractiveFailsFastInsteadOfPolling pins the CI-hang fix. +// +// Before: a step-up challenge raised in a --non-interactive session created a +// verification session and polled it until the session expired — nobody can +// approve a browser challenge in CI, so the command blocked for the whole +// session window and only then failed. After: it fails immediately, and never +// creates the unapprovable session in the first place. +// +// Node parity: src/lib/rechallenge/flow.ts:56 (`if (!interactive && !wait)`). +func TestFlowNonInteractiveFailsFastInsteadOfPolling(t *testing.T) { + var sessionsCreated int32 + srv := pendingForeverServer(t, &sessionsCreated) + + // Cancellable so a REGRESSION stops polling when the watchdog fires + // instead of leaking a hot goroutine into the rest of the package's tests. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tr := &fakeTracker{} + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: tr, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + } + + done := runAsync(ctx, r, RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: false, + Extension: testExtension(srv.URL), + }) + + select { + case err := <-done: + var ire *InteractionRequiredError + if !errors.As(err, &ire) { + t.Fatalf("err = %T (%v), want *InteractionRequiredError", err, err) + } + // The message has to say what happened AND what to do about it — + // a bare "permission denied" is what sent people spelunking. + for _, want := range []string{ + "updateDefensiveModeStatus", + "non-interactive", + "VIP_RECHALLENGE_WAIT=1", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error text must mention %q; got %q", want, err.Error()) + } + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return within 5s in a non-interactive session: " + + "step-up is polling a challenge nobody can approve (this is the CI hang)") + } + + if n := atomic.LoadInt32(&sessionsCreated); n != 0 { + t.Errorf("createSession called %d times; a non-interactive session must not "+ + "mint a verification challenge no human can complete", n) + } + if !containsString(tr.events, "rechallenge_interaction_required") { + t.Errorf("missing rechallenge_interaction_required event; got %v", tr.events) + } +} + +// TestFlowNonInteractiveWaitOptInStillPolls: the fail-fast must stay opt-out-able. +// An operator running headless who can approve on a phone sets +// VIP_RECHALLENGE_WAIT=1 (Node: --rechallenge-wait) and gets the old behavior. +func TestFlowNonInteractiveWaitOptInStillPolls(t *testing.T) { + mux := http.NewServeMux() + hour := func() string { return time.Now().Add(time.Hour).Format(time.RFC3339) } + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + hour() + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + hour() + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + mux.HandleFunc("/p/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"opaque","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"x"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + var out strings.Builder + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: &out, + Sleep: func(context.Context, time.Duration) error { return nil }, + } + tok, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: false, + Wait: true, + Extension: testExtension(srv.URL), + }) + if err != nil { + t.Fatalf("Run with Wait opt-in: %v", err) + } + if tok == nil || tok.Token != "opaque" { + t.Fatalf("token = %+v, want opaque", tok) + } + if !strings.Contains(out.String(), "https://example/v/c1") { + t.Errorf("waiting non-interactive run must print the verification URL; got %q", out.String()) + } +} + +// TestFlowRejectsUnusableDeadline: a session with no (or unparseable) expiresAt +// used to disable the deadline check entirely — `!deadline.IsZero()` meant the +// loop polled forever with nothing to stop it. Node throws immediately +// (flow.ts:93). Watchdogged for the same reason as above. +func TestFlowRejectsUnusableDeadline(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + // No expiresAt at all -> zero time. + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","pollIntervalSeconds":0}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + } + done := runAsync(ctx, r, RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: true, + Extension: testExtension(srv.URL), + }) + + select { + case err := <-done: + var terr *TerminalError + if !errors.As(err, &terr) { + t.Fatalf("err = %T (%v), want *TerminalError", err, err) + } + if terr.Status() != StatusExpired { + t.Errorf("status = %q, want %q", terr.Status(), StatusExpired) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return within 5s for a session with no expiresAt: " + + "the poll loop has no deadline and will never stop") + } +} + +// TestFlowFloorsServerPollInterval: pollIntervalSeconds of 0 (or absent, or +// negative) used to produce a zero sleep, i.e. an unthrottled status-poll loop +// against Parker for the life of the session. Node clamps to 2s +// (flow.ts:24 MIN_POLL_INTERVAL_SECONDS). +func TestFlowFloorsServerPollInterval(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"zero", `"pollIntervalSeconds":0,`}, + {"absent", ``}, + {"negative", `"pollIntervalSeconds":-5,`}, + {"below floor", `"pollIntervalSeconds":1,`}, + } { + t.Run(tc.name, func(t *testing.T) { + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v",` + tc.body + `"expiresAt":"` + hour + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + hour + `","pollIntervalSeconds":0,"provider":"p"}`)) + }) + mux.HandleFunc("/p/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"t","expiresAt":"` + hour + `","purpose":"x"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + var mu sync.Mutex + var slept []time.Duration + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(_ context.Context, d time.Duration) error { + mu.Lock() + slept = append(slept, d) + mu.Unlock() + return nil + }, + } + if _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "op", + Interactive: true, + Extension: testExtension(srv.URL), + }); err != nil { + t.Fatalf("Run: %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(slept) == 0 { + t.Fatal("poll loop never slept") + } + for _, d := range slept { + if d < MinPollInterval { + t.Errorf("slept %v, want >= %v (unthrottled polling hammers Parker)", d, MinPollInterval) + } + } + }) + } +} diff --git a/internal/rechallenge/flow_test.go b/internal/rechallenge/flow_test.go new file mode 100644 index 000000000..a64d00d0f --- /dev/null +++ b/internal/rechallenge/flow_test.go @@ -0,0 +1,203 @@ +package rechallenge + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fakeTracker struct { + mu sync.Mutex + events []string +} + +func (f *fakeTracker) Track(name string, _ map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, name) +} + +func TestFlowUnsupportedVersion(t *testing.T) { + cache := newTestCache() + tr := &fakeTracker{} + r := &Runner{Tracker: tr, TokenCache: cache} + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + Extension: Extension{ + Version: "v99", + CreateSessionPath: "/x", + StatusPathTemplate: "/x/{challengeId}", + ExchangePathTemplate: "/x/{challengeId}/e", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var ver *UnsupportedVersionError + if !errors.As(err, &ver) { + t.Fatalf("err = %T, want *UnsupportedVersionError", err) + } +} + +func TestFlowHappyPathVerified(t *testing.T) { + pollCount := int32(0) + mux := http.NewServeMux() + mux.HandleFunc("/p/v2/cli/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&pollCount, 1) + if n < 2 { + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0}`)) + return + } + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"opaque","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"doThing"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tr := &fakeTracker{} + cache := newTestCache() + var out bytes.Buffer + openCalled := int32(0) + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: tr, + TokenCache: cache, + Stdout: &out, + OpenURL: func(string) { atomic.AddInt32(&openCalled, 1) }, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + + tok, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/p/v2/cli/sessions", + StatusPathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ExchangePathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}/exchange", + ElevatedHeaderName: "x-elevated-token", + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if tok.Token != "opaque" { + t.Errorf("token = %q", tok.Token) + } + if atomic.LoadInt32(&openCalled) != 1 { + t.Errorf("OpenURL called %d times, want 1", openCalled) + } + wantSubseq := []string{ + "rechallenge_required", + "rechallenge_session_created", + "rechallenge_verified", + "rechallenge_exchanged", + } + for _, w := range wantSubseq { + if !containsString(tr.events, w) { + t.Errorf("missing event %q in %v", w, tr.events) + } + } + if !strings.Contains(out.String(), "https://example/v/c1") { + t.Errorf("stdout missing verification URL: %q", out.String()) + } + // Cache must hold the token under scope. + cached, _ := cache.Get("doThing") + if cached == nil || cached.Token != "opaque" { + t.Errorf("cache.Get = %+v, want token opaque", cached) + } +} + +func TestFlowTerminalCancelled(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/p/v2/cli/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"cancelled","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"statusReason":{"code":"user","message":"user cancelled"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + // Interactive: only an interactive (or explicitly waiting) session + // gets as far as polling — see TestFlowNonInteractiveFailsFastInsteadOfPolling. + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/p/v2/cli/sessions", + StatusPathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ExchangePathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}/x", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var terr *TerminalError + if !errors.As(err, &terr) { + t.Fatalf("err = %T (%v); want *TerminalError", err, err) + } + if terr.Status() != StatusCancelled { + t.Errorf("Status = %q", terr.Status()) + } + if !strings.Contains(err.Error(), "user cancelled") { + t.Errorf("err must include statusReason detail: %v", err) + } +} + +func TestFlowAbortedByContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":1,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Sleep: func(ctx context.Context, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + }, + } + cancel() // cancel before Run starts polling + _, err := r.Run(ctx, RunInput{ + RequestedOperation: "doThing", + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/x", + StatusPathTemplate: "/x/{challengeId}", + ExchangePathTemplate: "/x/{challengeId}/e", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var aerr *AbortedError + if !errors.As(err, &aerr) { + t.Errorf("err = %T, want *AbortedError", err) + } +} + +func containsString(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} diff --git a/internal/rechallenge/interactive.go b/internal/rechallenge/interactive.go new file mode 100644 index 000000000..23c65c7b9 --- /dev/null +++ b/internal/rechallenge/interactive.go @@ -0,0 +1,90 @@ +package rechallenge + +import ( + "os" + "slices" + "strings" + + "golang.org/x/term" +) + +// IsInteractiveContext returns true when interactive prompts and browser opens +// are appropriate. Mirrors src/lib/rechallenge/flow.ts:isInteractiveContext. +// +// Order: +// 1. VIP_NON_INTERACTIVE=1 → false +// 2. argv contains "--non-interactive" → false +// 3. else stdin-is-tty +// +// Sensed on STDIN for the same reason as appctx.IsInteractive (parity blocker +// B5): an approval has to be typed in, so stdout redirection is irrelevant. +func IsInteractiveContext(argv []string) bool { + return isInteractiveCheck(argv, term.IsTerminal(int(os.Stdin.Fd()))) +} + +// isInteractiveCheck is the testable core. The tty value is injected so +// tests don't depend on whether `go test` is run from a TTY. +func isInteractiveCheck(argv []string, tty bool) bool { + if os.Getenv("VIP_NON_INTERACTIVE") == "1" { + return false + } + if slices.Contains(argv, "--non-interactive") { + return false + } + return tty +} + +// WaitEnvVar opts back in to waiting for a step-up approval in a +// non-interactive session. Named as a constant because the error text that +// tells the user about it must not be able to drift from the variable actually +// read. +const WaitEnvVar = "VIP_RECHALLENGE_WAIT" + +// WaitFlag is the command-line half of the same opt-in. Registered by the +// commands that can trip step-up (see NewDefensiveModeCmd), matching where +// Node registers it — src/bin/vip-defensive-mode-{enable,disable,configure}.js. +const WaitFlag = "--rechallenge-wait" + +// ShouldWaitForRechallenge reports whether the caller has explicitly asked to +// block on a step-up challenge despite being non-interactive — the case where +// an operator running headless will approve on a phone. +// +// Without this, a non-interactive step-up fails fast (see +// NewInteractionRequiredError): the default has to be "fail", because the +// common non-interactive caller is CI, which cannot approve anything and would +// otherwise block until the verification session expires. +// +// Mirrors src/lib/rechallenge/flow.ts:185, including its argv scan and reason +// for it: the step-up middleware is built once at startup and cannot read a +// command's parsed options, so the flag is read from the raw command line even +// though cobra also parses it. +func ShouldWaitForRechallenge() bool { + return shouldWaitCheck(os.Args) +} + +// shouldWaitCheck is the testable core; argv is injected so tests don't have to +// mutate os.Args. +func shouldWaitCheck(argv []string) bool { + if os.Getenv(WaitEnvVar) == "1" { + return true + } + for _, item := range argv { + if item == WaitFlag { + return true + } + // `--rechallenge-wait=<value>` counts unless the value is a negation, + // so `--rechallenge-wait=false` does not silently mean true. + if value, ok := strings.CutPrefix(item, WaitFlag+"="); ok && !isNegation(value) { + return true + } + } + return false +} + +func isNegation(value string) bool { + switch strings.ToLower(value) { + case "0", "false", "no", "off": + return true + } + return false +} diff --git a/internal/rechallenge/interactive_test.go b/internal/rechallenge/interactive_test.go new file mode 100644 index 000000000..6199e7c2f --- /dev/null +++ b/internal/rechallenge/interactive_test.go @@ -0,0 +1,106 @@ +package rechallenge + +import ( + "os" + "strings" + "testing" + + "github.com/creack/pty" +) + +// Same stdout-vs-stdin sensor bug as appctx.IsInteractive (parity blocker B5). +// This one is live: gql/rechallenge.go:111 and main.go:143 use it to decide +// whether a step-up approval can be prompted for, so a redirected stdout made +// step-up give up on a perfectly interactive terminal. +func TestIsInteractiveContextSensesStdinNotStdout(t *testing.T) { + ptmx, tty, err := pty.Open() + if err != nil { + t.Skipf("pty unavailable: %v", err) + } + defer func() { _ = ptmx.Close(); _ = tty.Close() }() + redirected, err := os.CreateTemp(t.TempDir(), "redirected") + if err != nil { + t.Fatal(err) + } + defer redirected.Close() + + origIn, origOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = tty, redirected + defer func() { os.Stdin, os.Stdout = origIn, origOut }() + + if !IsInteractiveContext(nil) { + t.Error("stdin is a TTY and only stdout is redirected: step-up must still be promptable") + } +} + +// ShouldWaitForRechallenge is the opt-out from the non-interactive fail-fast: +// an operator who can approve on another device asks for the old polling +// behavior explicitly. Mirrors the environment half of +// src/lib/rechallenge/flow.ts:shouldWaitForRechallenge. +func TestShouldWaitForRechallenge(t *testing.T) { + tests := []struct { + name string + argv []string + env string + setEnv bool + want bool + }{ + {name: "default off", want: false}, + {name: "env =1", env: "1", setEnv: true, want: true}, + {name: "env =0 stays off", env: "0", setEnv: true, want: false}, + {name: "env =true is not 1", env: "true", setEnv: true, want: false}, + {name: "env empty stays off", env: "", setEnv: true, want: false}, + {name: "bare flag", argv: []string{"defensive-mode", "enable", "--rechallenge-wait"}, want: true}, + {name: "flag=true", argv: []string{"--rechallenge-wait=true"}, want: true}, + {name: "flag=false", argv: []string{"--rechallenge-wait=false"}, want: false}, + {name: "flag=0", argv: []string{"--rechallenge-wait=0"}, want: false}, + {name: "flag=OFF", argv: []string{"--rechallenge-wait=OFF"}, want: false}, + {name: "similar flag does not count", argv: []string{"--rechallenge-waiting"}, want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setEnv { + t.Setenv(WaitEnvVar, tc.env) + } + if got := shouldWaitCheck(tc.argv); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +// The message that tells the user how to opt in must name the variable the code +// actually reads. These drift apart the moment they are two independent +// strings. +func TestInteractionRequiredErrorNamesTheRealEnvVar(t *testing.T) { + err := NewInteractionRequiredError("updateDefensiveModeStatus") + if !strings.Contains(err.Error(), WaitEnvVar+"=1") { + t.Errorf("error must tell the user the opt-in that exists; got %q", err.Error()) + } +} + +func TestIsInteractiveContext(t *testing.T) { + tests := []struct { + name string + argv []string + env map[string]string + tty bool + want bool + }{ + {"tty + no overrides", nil, nil, true, true}, + {"non-tty", nil, nil, false, false}, + {"VIP_NON_INTERACTIVE=1", nil, map[string]string{"VIP_NON_INTERACTIVE": "1"}, true, false}, + {"--non-interactive in argv", []string{"defensive-mode", "enable", "--non-interactive"}, nil, true, false}, + {"VIP_NON_INTERACTIVE empty doesn't disable", nil, map[string]string{"VIP_NON_INTERACTIVE": ""}, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + if got := isInteractiveCheck(tc.argv, tc.tty); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/rechallenge/redact.go b/internal/rechallenge/redact.go new file mode 100644 index 000000000..b759f19ae --- /dev/null +++ b/internal/rechallenge/redact.go @@ -0,0 +1,36 @@ +package rechallenge + +import ( + "regexp" + "strings" +) + +// jwtInText matches a JSON Web Token by its header segment rather than by the +// generic three-dotted-segments shape. +// +// Every JWT header is base64url-encoded JSON, so it always begins with the +// encoding of `{"` — "eyJ". Anchoring on that is what keeps this usable: the +// unanchored `seg.seg.seg` form that internal/parity uses is fine for a test +// harness, but here it would also swallow ordinary dotted hostnames +// ("parker-service.production.example") out of the very error text we are +// adding for diagnosis. The authoritative protection is the explicit secret +// list below — this pattern is the net for a token we were never handed. +var jwtInText = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*`) + +// RedactSecrets strips credentials from server-controlled text before it is +// shown, logged, or shipped. +// +// Every string that passes through here is on its way somewhere durable: the +// user's terminal, a CI log, and cmd/vip-next/main.go's exit hook, which posts +// error text to the telemetry endpoint. Parker echoes request context into some +// error payloads, so a response body can carry back the Authorization header we +// sent it. Pass every credential in scope as a secret; an empty secret is +// ignored so a zero-valued token cannot blank the whole message. +func RedactSecrets(value string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "<redacted>") + } + } + return jwtInText.ReplaceAllString(value, "<redacted-jwt>") +} diff --git a/internal/rechallenge/redact_test.go b/internal/rechallenge/redact_test.go new file mode 100644 index 000000000..576e6667a --- /dev/null +++ b/internal/rechallenge/redact_test.go @@ -0,0 +1,142 @@ +package rechallenge + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const fakeBearer = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.c2lnbmF0dXJlLWJ5dGVz" + +func TestRedactSecrets(t *testing.T) { + tests := []struct { + name string + in string + secrets []string + absent []string + present []string + }{ + { + name: "known secret is replaced verbatim", + in: "upstream rejected Authorization: Bearer " + fakeBearer, + secrets: []string{fakeBearer}, + absent: []string{fakeBearer}, + present: []string{"upstream rejected"}, + }, + { + name: "a JWT we were never handed is still caught", + in: `{"echo":{"headers":{"authorization":"Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJvdGhlciJ9.xyz"}}}`, + secrets: nil, + absent: []string{"eyJhbGciOiJub25lIn0.eyJzdWIiOiJvdGhlciJ9.xyz"}, + }, + { + // The unanchored seg.seg.seg pattern would eat this, gutting the + // diagnosis the surfaced reason exists to provide. + name: "dotted hostnames survive", + in: "step-up provider parker-service.production.example refused the request", + present: []string{"parker-service.production.example"}, + }, + { + name: "empty secret does not blank the whole string", + in: "session window elapsed", + secrets: []string{""}, + present: []string{"session window elapsed"}, + }, + { + name: "ordinary text is untouched", + in: "Step-up verification did not complete (status=cancelled): user cancelled.", + present: []string{"user cancelled"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := RedactSecrets(tc.in, tc.secrets...) + for _, a := range tc.absent { + if strings.Contains(got, a) { + t.Errorf("redacted text still contains %q: %s", a, got) + } + } + for _, p := range tc.present { + if !strings.Contains(got, p) { + t.Errorf("redacted text lost %q: %s", p, got) + } + } + }) + } +} + +// TestHttpErrorRedactsBearerToken: Parker echoes request context into some +// error payloads, and these error strings now reach the user's terminal, CI +// logs, and the telemetry exit hook. The response body must never be able to +// carry the bearer token back out. +func TestHttpErrorRedactsBearerToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + // Worst case: the server reflects the request it received. + w.Write([]byte(`{"error":"forbidden","request":{"authorization":"Bearer ` + + r.Header.Get("Authorization") + `"}}`)) + })) + defer srv.Close() + + c := &Client{APIHost: srv.URL, HTTP: srv.Client(), BearerToken: fakeBearer} + _, err := c.CreateSession(CreateSessionInput{Path: "/x", RequestedOperation: "op"}) + if err == nil { + t.Fatal("want an error for HTTP 403") + } + if strings.Contains(err.Error(), fakeBearer) { + t.Fatalf("bearer token leaked into error text: %s", err.Error()) + } + if !strings.Contains(err.Error(), "forbidden") { + t.Errorf("redaction ate the diagnosis; want the server reason, got: %s", err.Error()) + } + + var herr *HttpError + if !errors.As(err, &herr) { + t.Fatalf("err = %T, want *HttpError", err) + } + if strings.Contains(herr.BodyText(), fakeBearer) { + t.Errorf("bearer token leaked via BodyText(): %s", herr.BodyText()) + } +} + +// TestFlowRedactsStatusReason: statusReason.message is server-controlled text +// that lands in the TerminalError the user sees. +func TestFlowRedactsStatusReason(t *testing.T) { + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + hour + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"failed","expiresAt":"` + hour + + `","pollIntervalSeconds":0,"statusReason":{"code":"x","message":"denied for token ` + fakeBearer + `"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client(), BearerToken: fakeBearer}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: new(strings.Builder), + Sleep: func(context.Context, time.Duration) error { return nil }, + } + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "op", + Interactive: true, + Extension: testExtension(srv.URL), + }) + if err == nil { + t.Fatal("want a TerminalError") + } + if strings.Contains(err.Error(), fakeBearer) { + t.Fatalf("bearer token leaked via statusReason: %s", err.Error()) + } + if !strings.Contains(err.Error(), "denied for token") { + t.Errorf("the server's reason must survive redaction; got: %s", err.Error()) + } +} diff --git a/internal/rechallenge/tokencache.go b/internal/rechallenge/tokencache.go new file mode 100644 index 000000000..ff0d175bc --- /dev/null +++ b/internal/rechallenge/tokencache.go @@ -0,0 +1,148 @@ +package rechallenge + +import ( + "errors" + "regexp" + "sync" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/keychain" +) + +// baseServiceName keeps vip-next's elevated tokens isolated from the Node CLI. +const baseServiceName = "vip-next-cli:elevated" + +// nonAlphanumericTC matches the sanitization used by Node's +// API_HOST.replace(/[^a-z0-9]/gi, '-') so production and non-prod hosts get +// distinct service entries. +var nonAlphanumericTC = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// ServiceNameForHost builds the keychain service name for the elevated-token +// cache. Returns the bare base name for the production API host; otherwise +// suffixes ":<sanitized-host>". +func ServiceNameForHost(apiHost string) string { + if apiHost == keychain.ProductionAPIHost { + return baseServiceName + } + return baseServiceName + ":" + nonAlphanumericTC.ReplaceAllString(apiHost, "-") +} + +// TokenCache stores elevated tokens per scope in a single Go-owned keychain +// entry. The on-disk shape is a JSON blob {scope: ElevatedToken}, keeping +// ClearAll cheap while preserving the Node data shape. +type TokenCache struct { + Keychain *keychain.Keychain + mu sync.Mutex + loaded bool + blob map[string]ElevatedToken +} + +func (c *TokenCache) load() error { + if c.loaded { + return nil + } + raw, err := c.Keychain.Backend.Get(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + c.blob = map[string]ElevatedToken{} + c.loaded = true + return nil + } + if err != nil { + return err + } + parsed := map[string]ElevatedToken{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + // Corrupted blob → drop and reset (matches Node). + _ = c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + c.blob = map[string]ElevatedToken{} + c.loaded = true + return nil + } + c.blob = parsed + c.loaded = true + return nil +} + +func (c *TokenCache) write() error { + if len(c.blob) == 0 { + err := c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err + } + data, err := json.Marshal(c.blob, json.Deterministic(true)) + if err != nil { + return err + } + return c.Keychain.Backend.Set(c.Keychain.Service, c.Keychain.Service, string(data)) +} + +// Get returns the cached token for scope, or nil if missing/expired. +// Expired tokens are evicted as a side effect (matches Node). +func (c *TokenCache) Get(scope string) (*ElevatedToken, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return nil, err + } + tok, ok := c.blob[scope] + if !ok { + return nil, nil + } + if isExpired(tok) { + delete(c.blob, scope) + if err := c.write(); err != nil { + return nil, err + } + return nil, nil + } + return &tok, nil +} + +func (c *TokenCache) Set(scope string, tok ElevatedToken) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return err + } + c.blob[scope] = tok + return c.write() +} + +func (c *TokenCache) ClearScope(scope string) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return err + } + if _, ok := c.blob[scope]; !ok { + return nil + } + delete(c.blob, scope) + return c.write() +} + +// ClearAll drops the keychain entry entirely. Called on logout. +func (c *TokenCache) ClearAll() error { + c.mu.Lock() + defer c.mu.Unlock() + c.blob = map[string]ElevatedToken{} + c.loaded = true + err := c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err +} + +// isExpired matches Node's 5-second grace window. A token whose ExpiresAt is +// within the next 5 seconds counts as expired. +func isExpired(tok ElevatedToken) bool { + if tok.ExpiresAt.IsZero() { + return true + } + return time.Now().Add(5 * time.Second).After(tok.ExpiresAt) +} diff --git a/internal/rechallenge/tokencache_test.go b/internal/rechallenge/tokencache_test.go new file mode 100644 index 000000000..a356ec0ba --- /dev/null +++ b/internal/rechallenge/tokencache_test.go @@ -0,0 +1,139 @@ +package rechallenge + +import ( + "testing" + "time" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func newTestCache() *TokenCache { + return &TokenCache{ + Keychain: &keychain.Keychain{Backend: &memBackend{}, Service: "vip-next-cli:elevated"}, + } +} + +func TestTokenCacheRoundTrip(t *testing.T) { + c := newTestCache() + tok := ElevatedToken{Token: "x", ExpiresAt: time.Now().Add(1 * time.Hour), Purpose: "u"} + if err := c.Set("doThing", tok); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := c.Get("doThing") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil || got.Token != "x" { + t.Errorf("Get = %+v, want token x", got) + } +} + +func TestTokenCacheMissingReturnsNil(t *testing.T) { + c := newTestCache() + got, err := c.Get("absent") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != nil { + t.Errorf("Get(absent) = %+v, want nil", got) + } +} + +func TestTokenCacheExpiredEvicted(t *testing.T) { + c := newTestCache() + // Expires within the 5s grace window — counts as expired. + c.Set("doThing", ElevatedToken{Token: "x", ExpiresAt: time.Now().Add(2 * time.Second)}) + got, err := c.Get("doThing") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != nil { + t.Errorf("expired token must be evicted; got %+v", got) + } + // Fresh cache reading the same backend must not see the entry either. + c2 := &TokenCache{Keychain: c.Keychain} + got2, _ := c2.Get("doThing") + if got2 != nil { + t.Errorf("after eviction the keychain blob must not contain doThing; got %+v", got2) + } +} + +func TestTokenCacheClearScope(t *testing.T) { + c := newTestCache() + c.Set("a", ElevatedToken{Token: "a", ExpiresAt: time.Now().Add(time.Hour)}) + c.Set("b", ElevatedToken{Token: "b", ExpiresAt: time.Now().Add(time.Hour)}) + if err := c.ClearScope("a"); err != nil { + t.Fatalf("ClearScope: %v", err) + } + got, _ := c.Get("a") + if got != nil { + t.Errorf("a should be cleared") + } + got, _ = c.Get("b") + if got == nil { + t.Errorf("b should still be present") + } +} + +func TestTokenCacheClearAll(t *testing.T) { + c := newTestCache() + c.Set("a", ElevatedToken{Token: "a", ExpiresAt: time.Now().Add(time.Hour)}) + c.Set("b", ElevatedToken{Token: "b", ExpiresAt: time.Now().Add(time.Hour)}) + if err := c.ClearAll(); err != nil { + t.Fatalf("ClearAll: %v", err) + } + gotA, _ := c.Get("a") + gotB, _ := c.Get("b") + if gotA != nil || gotB != nil { + t.Errorf("ClearAll must drop everything; got a=%+v b=%+v", gotA, gotB) + } + be := c.Keychain.Backend.(*memBackend) + if _, exists := be.store["vip-next-cli:elevated|vip-next-cli:elevated"]; exists { + t.Errorf("keychain entry must be deleted after ClearAll") + } +} + +func TestTokenCacheCorruptedBlobIsReset(t *testing.T) { + c := newTestCache() + be := c.Keychain.Backend.(*memBackend) + _ = be.Set("vip-next-cli:elevated", "vip-next-cli:elevated", "not-json") + got, err := c.Get("anything") + if err != nil { + t.Fatalf("Get on corrupt blob should not error; got %v", err) + } + if got != nil { + t.Errorf("corrupted blob should yield nil; got %+v", got) + } +} + +func TestServiceNameForElevatedTokens(t *testing.T) { + if got := ServiceNameForHost("https://api.wpvip.com"); got != "vip-next-cli:elevated" { + t.Errorf("prod = %q, want vip-next-cli:elevated", got) + } + if got := ServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-next-cli:elevated:https---staging-api-wpvip-com-8443" { + t.Errorf("non-prod = %q", got) + } +} diff --git a/internal/rechallenge/types.go b/internal/rechallenge/types.go new file mode 100644 index 000000000..6bca9e888 --- /dev/null +++ b/internal/rechallenge/types.go @@ -0,0 +1,100 @@ +// Package rechallenge implements the Rechallenge v2 step-up authentication +// flow. Mirrors src/lib/rechallenge/* in the Node implementation. +// +// Contract guarantees (load-bearing per project_rechallenge_v2.md): +// - Parker path templates and the elevated-header name come from +// extensions.rechallenge ON EACH response, never hardcoded. +// - Only mutations are eligible for step-up; queries surface errors unchanged. +// - The elevated-token cache uses a single keychain entry shared with the +// Node binary so logged-in state crosses binaries. +package rechallenge + +import "time" + +const ( + // ElevatedPermissionErrorCode matches src/lib/rechallenge/types.ts. + ElevatedPermissionErrorCode = "elevated-permission-required" + // Version is the rechallenge protocol version this client supports. + Version = "v2" + // ClientType is sent in createSession to identify the caller. + ClientType = "cli" +) + +// Status mirrors RechallengeStatus from types.ts. +type Status string + +const ( + StatusPending Status = "pending" + StatusVerified Status = "verified" + StatusExpired Status = "expired" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +// IsTerminal reports whether the status indicates the flow should stop polling. +func (s Status) IsTerminal() bool { + switch s { + case StatusVerified, StatusExpired, StatusFailed, StatusCancelled: + return true + } + return false +} + +// Extension is the shape of errors[0].extensions.rechallenge from the API. +type Extension struct { + Version string `json:"version"` + CreateSessionPath string `json:"createSessionPath"` + StatusPathTemplate string `json:"statusPathTemplate"` + ExchangePathTemplate string `json:"exchangePathTemplate"` + ElevatedHeaderName string `json:"elevatedHeaderName"` +} + +// IsValid reports whether the extension has all required template fields. +// Mirrors the typeof checks in link.ts:extractElevatedPermission. +func (e Extension) IsValid() bool { + return e.CreateSessionPath != "" && + e.StatusPathTemplate != "" && + e.ExchangePathTemplate != "" && + e.ElevatedHeaderName != "" +} + +// Session is the response from POST {createSessionPath}. +type Session struct { + ChallengeID string `json:"challengeId"` + Status Status `json:"status"` + VerificationURL string `json:"verificationUrl"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// StatusReason is the optional explanation returned with a terminal status. +type StatusReason struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// SessionStatus is the response from GET {statusPathTemplate}. +type SessionStatus struct { + ChallengeID string `json:"challengeId"` + Status Status `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + VerifiedAt *time.Time `json:"verifiedAt,omitempty"` + Provider string `json:"provider,omitempty"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + StatusReason *StatusReason `json:"statusReason,omitempty"` +} + +// ExchangeResponse is the response from POST {exchangePathTemplate}. +type ExchangeResponse struct { + ElevatedToken ElevatedToken `json:"elevatedToken"` +} + +// ElevatedToken is the elevated bearer issued after successful step-up. +// HeaderName is set by the flow orchestrator (copied from Extension.ElevatedHeaderName) +// so the link layer doesn't need to consult the Extension during replay. +type ElevatedToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` + Purpose string `json:"purpose"` + HeaderName string `json:"headerName,omitempty"` +} diff --git a/internal/rechallenge/types_test.go b/internal/rechallenge/types_test.go new file mode 100644 index 000000000..09225f329 --- /dev/null +++ b/internal/rechallenge/types_test.go @@ -0,0 +1,56 @@ +package rechallenge + +import ( + "testing" + "time" + + json "encoding/json/v2" +) + +func TestSessionDecode(t *testing.T) { + in := `{"challengeId":"abc","status":"pending","verificationUrl":"https://parker.example/verify/abc","pollIntervalSeconds":2,"expiresAt":"2026-06-05T12:00:00Z"}` + var s Session + if err := json.Unmarshal([]byte(in), &s); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if s.ChallengeID != "abc" { + t.Errorf("ChallengeID = %q, want abc", s.ChallengeID) + } + if s.Status != StatusPending { + t.Errorf("Status = %q, want pending", s.Status) + } + if s.PollIntervalSeconds != 2 { + t.Errorf("PollIntervalSeconds = %d, want 2", s.PollIntervalSeconds) + } + if want := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC); !s.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", s.ExpiresAt, want) + } +} + +func TestExtensionDecode(t *testing.T) { + in := `{"version":"v2","createSessionPath":"/p/v2/cli/sessions","statusPathTemplate":"/p/v2/cli/sessions/{challengeId}","exchangePathTemplate":"/p/v2/cli/sessions/{challengeId}/elevated-token","elevatedHeaderName":"x-elevated-token"}` + var e Extension + if err := json.Unmarshal([]byte(in), &e); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if e.Version != Version { + t.Errorf("Version = %q, want %q", e.Version, Version) + } + if e.ElevatedHeaderName != "x-elevated-token" { + t.Errorf("ElevatedHeaderName = %q", e.ElevatedHeaderName) + } +} + +func TestElevatedTokenDecode(t *testing.T) { + in := `{"token":"opaque","expiresAt":"2026-06-05T13:00:00Z","purpose":"updateDefensiveModeStatus","headerName":"x-elevated-token"}` + var tok ElevatedToken + if err := json.Unmarshal([]byte(in), &tok); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if tok.Token != "opaque" { + t.Errorf("Token = %q", tok.Token) + } + if tok.Purpose != "updateDefensiveModeStatus" { + t.Errorf("Purpose = %q", tok.Purpose) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 000000000..a1c75551b --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,99 @@ +// Package redact removes credentials from text that is about to leave the +// process — an error message printed to a shared terminal, written to a log +// file, or, in vip-next's case, shipped to an analytics endpoint by the +// cli_error telemetry hook. +// +// It is the production counterpart of internal/parity's RedactSecrets, which is +// test-only and takes the secrets it should remove as arguments. Here the +// secrets are not known in advance: they arrive inside error strings minted by +// net/http, which embeds the full request URL — query string and all — in every +// *url.Error it returns. +// +// The design constraint is that this must be safe to apply unconditionally. A +// scrubber that mangles ordinary messages produces unreadable errors and gets +// switched off, so every rule here is anchored on a shape that does not occur +// in prose: a URL's query or userinfo, a JWT's "eyJ" header prefix, an explicit +// Bearer keyword. +package redact + +import ( + "net/url" + "regexp" + "strings" +) + +const ( + placeholderQuery = "<redacted>" + placeholderUserinfo = "xxxxx" + placeholderJWT = "<redacted-jwt>" +) + +// urlRE matches an absolute URL up to the first character that cannot appear in +// one unescaped. Quotes are terminators because net/http quotes the URL in +// *url.Error: `Get "https://…": dial tcp …`. +var urlRE = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s"'` + "`" + `<>]+`) + +// jwtRE is anchored on "eyJ", the base64 of `{"` that opens every JWT header. +// +// The looser `<8+>.<8+>.<any>` shape internal/parity uses is wrong for +// production text: it matches hostnames. "public-api.wordpress.com" satisfies +// it, and redacting the API host out of every network error would make the +// telemetry useless and the local message baffling. +var jwtRE = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}(?:\.[A-Za-z0-9_-]+)?`) + +// bearerRE catches a token that reached the message through a header dump +// rather than a URL. +var bearerRE = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}`) + +// trailingPunctuation is stripped from a URL match before parsing and restored +// after, so "see https://x/y?t=1." does not fold the sentence's full stop into +// the URL. +const trailingPunctuation = `.,;:!)]}` + +// Text returns s with every credential-shaped substring replaced. +// +// Removed: URL query strings (where presigned credentials live), URL userinfo +// (proxy passwords), URL fragments (implicit-flow tokens), JWTs, and Bearer +// tokens. Preserved: scheme, host, port and path of every URL, and all +// surrounding prose — the parts that make an error diagnosable. +func Text(s string) string { + s = urlRE.ReplaceAllStringFunc(s, redactURL) + s = jwtRE.ReplaceAllString(s, placeholderJWT) + s = bearerRE.ReplaceAllString(s, "Bearer "+placeholderQuery) + return s +} + +func redactURL(match string) string { + trimmed := strings.TrimRight(match, trailingPunctuation) + suffix := match[len(trimmed):] + + u, err := url.Parse(trimmed) + if err != nil { + // Unparseable, but a "?" still means everything after it is a query. + // Cut textually rather than let a malformed URL smuggle a signature out. + if q := strings.Index(trimmed, "?"); q >= 0 { + return trimmed[:q] + "?" + placeholderQuery + suffix + } + return match + } + + changed := false + if u.User != nil { + u.User = url.User(placeholderUserinfo) + changed = true + } + if u.RawQuery != "" && u.RawQuery != placeholderQuery { + // RawQuery is emitted verbatim by URL.String(), so the placeholder + // survives as written and re-running Text is a no-op. + u.RawQuery = placeholderQuery + changed = true + } + if u.Fragment != "" && u.Fragment != placeholderQuery { + u.Fragment = placeholderQuery + changed = true + } + if !changed { + return match + } + return u.String() + suffix +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 000000000..78b2bc50f --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,80 @@ +package redact + +import ( + "strings" + "testing" +) + +func TestTextStripsURLQueryStrings(t *testing.T) { + // The presigned URLs vip-next handles — media-import error reports, SQL + // export downloads, upload presigns — put the credential IN the query + // string. Possession of the query is the authorisation. + in := `Get "https://vip-media.s3.amazonaws.com/report.json?X-Amz-Signature=deadbeefcafe&X-Amz-Credential=AKIAEXAMPLE": dial tcp: i/o timeout` + got := Text(in) + + for _, secret := range []string{"X-Amz-Signature", "deadbeefcafe", "AKIAEXAMPLE"} { + if strings.Contains(got, secret) { + t.Errorf("Text kept %q:\n\t%s", secret, got) + } + } + // The diagnosable parts must survive: which host, which object, what failed. + for _, keep := range []string{"vip-media.s3.amazonaws.com", "report.json", "i/o timeout"} { + if !strings.Contains(got, keep) { + t.Errorf("Text dropped %q, which the report needs to stay useful:\n\t%s", keep, got) + } + } +} + +func TestTextStripsURLUserinfo(t *testing.T) { + got := Text("SOCKS proxy socks5://alice:hunter2@proxy.corp.example:1080 has no host") + if strings.Contains(got, "hunter2") || strings.Contains(got, "alice") { + t.Errorf("Text kept proxy credentials:\n\t%s", got) + } + if !strings.Contains(got, "proxy.corp.example:1080") { + t.Errorf("Text dropped the proxy host, which the user needs to fix their config:\n\t%s", got) + } +} + +func TestTextStripsJWTs(t *testing.T) { + jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk" + got := Text("token rejected: " + jwt) + if strings.Contains(got, jwt) || strings.Contains(got, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") { + t.Errorf("Text kept a JWT:\n\t%s", got) + } + if !strings.Contains(got, "token rejected") { + t.Errorf("Text dropped the surrounding message:\n\t%s", got) + } +} + +func TestTextStripsBearerTokens(t *testing.T) { + got := Text("request failed with header Authorization: Bearer abc123SECRETvalue.and-more") + if strings.Contains(got, "abc123SECRETvalue") { + t.Errorf("Text kept a bearer token:\n\t%s", got) + } +} + +// TestTextLeavesOrdinaryMessagesAlone is the counterweight. A scrubber that +// mangles every message is one people will disable. Hostnames in particular +// look JWT-ish to a naive `a.b.c` regex — internal/parity's RedactSecrets uses +// exactly such a pattern, and it would eat "public-api.wordpress.com". +func TestTextLeavesOrdinaryMessagesAlone(t *testing.T) { + for _, msg := range []string{ + "failed to reach public-api.wordpress.com: connection refused", + "environment my-site is not running; run `vip dev-env start`", + "GraphQL error: You do not have permission to access this application", + "open versions.json: no such file or directory", + "https://api.wpvip.com/graphql returned 502", + } { + if got := Text(msg); got != msg { + t.Errorf("Text rewrote an innocuous message:\n\tin: %s\n\tout: %s", msg, got) + } + } +} + +func TestTextIsIdempotent(t *testing.T) { + in := `Get "https://example.com/a?sig=abc": refused` + once := Text(in) + if twice := Text(once); twice != once { + t.Errorf("Text is not idempotent:\n\t1x: %s\n\t2x: %s", once, twice) + } +} diff --git a/internal/searchreplace/dumpdetails.go b/internal/searchreplace/dumpdetails.go new file mode 100644 index 000000000..10741f0ad --- /dev/null +++ b/internal/searchreplace/dumpdetails.go @@ -0,0 +1,104 @@ +// Package searchreplace ports src/lib/search-and-replace.ts by shelling +// out to the existing Go `go-search-replace` binary (design §7.3 — NOT a +// reimplementation), plus the SQL-dump-type sniffing from +// src/lib/database.ts that the replace pipeline depends on. +package searchreplace + +import ( + "bufio" + "compress/gzip" + "io" + "os" + "regexp" + "strings" +) + +// DumpType mirrors Node's SqlDumpType enum (database.ts:8). +type DumpType string + +const ( + DumpTypeMyDumper DumpType = "MYDUMPER" + DumpTypeMysqldump DumpType = "MYSQLDUMP" +) + +// DumpDetails mirrors SqlDumpDetails (database.ts:13). +type DumpDetails struct { + Type DumpType + SourceDB string +} + +var ( + // database.ts:44 + metadataHeaderRE = regexp.MustCompile(`^-- metadata\.header `) + // database.ts:46 + sourceDBRE = regexp.MustCompile(`^-- (.*)-schema-create\.sql`) + // fixMyDumperRE — database.ts:110. + fixMyDumperRE = regexp.MustCompile(`^-- ([^ ]+) \d+$`) +) + +// GetSqlDumpDetails ports getSqlDumpDetails (database.ts:18): scan up to +// the first ~100 non-empty lines for mydumper markers. Transparent .gz +// support (suffix-based, like Node). +func GetSqlDumpDetails(filePath string) (DumpDetails, error) { + f, err := os.Open(filePath) // #nosec G304 -- caller-supplied CLI path + if err != nil { + return DumpDetails{}, err + } + defer f.Close() + + var r io.Reader = f + if strings.HasSuffix(filePath, ".gz") { + zr, err := gzip.NewReader(f) + if err != nil { + return DumpDetails{}, err + } + defer zr.Close() + r = zr + } + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + isMyDumper := false + sourceDB := "" + lineNo := 0 + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + if metadataHeaderRE.MatchString(line) { + isMyDumper = true + } + if m := sourceDBRE.FindStringSubmatch(line); m != nil && sourceDB == "" { + sourceDB = m[1] + } + if isMyDumper && sourceDB != "" { + // all fields found? end the search early (database.ts:57) + break + } + if lineNo > 100 { + // database.ts:62 — assume not mydumper past the 100th line + break + } + lineNo++ + } + if err := scanner.Err(); err != nil { + return DumpDetails{}, err + } + typ := DumpTypeMysqldump + if isMyDumper { + typ = DumpTypeMyDumper + } + return DumpDetails{Type: typ, SourceDB: sourceDB}, nil +} + +// FixMyDumperLine ports fixMyDumperTransform's per-line rewrite +// (database.ts:109): `-- <table> <n>` becomes `-- <table> -1`. +func FixMyDumperLine(line string) string { + m := fixMyDumperRE.FindStringSubmatch(line) + if m == nil { + return line + } + return "-- " + m[1] + " -1" +} diff --git a/internal/searchreplace/dumpdetails_test.go b/internal/searchreplace/dumpdetails_test.go new file mode 100644 index 000000000..e158b16b2 --- /dev/null +++ b/internal/searchreplace/dumpdetails_test.go @@ -0,0 +1,86 @@ +package searchreplace + +import ( + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, name, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestGetSqlDumpDetailsMyDumper(t *testing.T) { + p := write(t, "d.sql", "-- metadata.header 1\n-- mydb-schema-create.sql 0\nSELECT 1;\n") + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMyDumper || d.SourceDB != "mydb" { + t.Errorf("details = %+v", d) + } +} + +func TestGetSqlDumpDetailsMysqldump(t *testing.T) { + p := write(t, "d.sql", "-- MySQL dump 10.13\nCREATE TABLE wp_posts;\n") + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMysqldump { + t.Errorf("details = %+v", d) + } +} + +func TestGetSqlDumpDetailsStopsAt100Lines(t *testing.T) { + content := strings.Repeat("SELECT 1;\n", 150) + "-- metadata.header 1\n" + p := write(t, "d.sql", content) + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMysqldump { + t.Error("metadata.header after line 100 must not flip the type (database.ts:62)") + } +} + +func TestGetSqlDumpDetailsGz(t *testing.T) { + p := filepath.Join(t.TempDir(), "d.sql.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + if _, err := zw.Write([]byte("-- metadata.header 1\n-- gzdb-schema-create.sql 0\n")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMyDumper || d.SourceDB != "gzdb" { + t.Errorf("details = %+v", d) + } +} + +func TestFixMyDumperLine(t *testing.T) { + if got := FixMyDumperLine("-- wp_posts 12345"); got != "-- wp_posts -1" { + t.Errorf("got %q", got) + } + if got := FixMyDumperLine("INSERT INTO wp_posts VALUES (1);"); got != "INSERT INTO wp_posts VALUES (1);" { + t.Errorf("non-matching line altered: %q", got) + } +} diff --git a/internal/searchreplace/searchreplace.go b/internal/searchreplace/searchreplace.go new file mode 100644 index 000000000..7a4ba9d49 --- /dev/null +++ b/internal/searchreplace/searchreplace.go @@ -0,0 +1,247 @@ +package searchreplace + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// InPlaceConfirmMessage is the prompt Node shows before an irreversible +// in-place rewrite (search-and-replace.ts:152-155). Node's enquirer confirm +// defaults to No, so callers must pass defaultYes=false. +const InPlaceConfirmMessage = "Are you sure you want to run search and replace on your input file? This operation is not reversible." + +// Options mirror SearchReplaceOptions (search-and-replace.ts:101). +// +// The in-place confirm is the CALLER's job, mirroring Node's batchMode gate +// (`inPlace && !batchMode`, ts:151). Which callers prompt is NOT uniform, so +// check the Node source before adding or removing one: +// +// - platform `vip import sql` passes batchMode:true (vip-import-sql.js:732) +// and must NOT prompt — the command has already confirmed. +// - standalone `vip search-replace` passes no batchMode +// (vip-search-replace.js:74) and DOES prompt. +// - `vip dev-env import sql` reaches this through resolveImportPath with no +// batchMode (dev-environment-core.ts:854) and DOES prompt. +type Options struct { + InPlace bool + Output string // non-empty => write to this path; empty + !InPlace => temp file +} + +// Result mirrors SearchReplaceOutput (search-and-replace.ts:108). +type Result struct { + InputFileName string + OutputFileName string + UsingStdOut bool // always false in M7a (import path never streams to stdout) +} + +// ResolveBinary finds go-search-replace per design §7.3: +// $VIP_SEARCH_REPLACE_BIN → <executable-dir>/bin/go-search-replace[.exe] +// → <executable-dir>/go-search-replace[.exe] (sibling — where `make build` +// drops the bundled binary next to bin/vip-next) → PATH. +func ResolveBinary() (string, error) { + if p := os.Getenv("VIP_SEARCH_REPLACE_BIN"); p != "" { + return p, nil + } + name := "go-search-replace" + if runtime.GOOS == "windows" { + name += ".exe" + } + if exe, err := os.Executable(); err == nil { + if p, ok := lookupBundled(exe, name); ok { + return p, nil + } + } + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + return "", errors.New("unable to locate the go-search-replace binary; set VIP_SEARCH_REPLACE_BIN or add go-search-replace to PATH") +} + +// lookupBundled resolves any symlink on exePath (vip-next is commonly run via a +// PATH symlink like ~/.local/bin/vip-next, and os.Executable() returns the +// symlink, not its target, on macOS), then looks for <name> under <dir>/bin/ +// (release-tarball layout) or <dir>/ (sibling — where `make build` drops it). +func lookupBundled(exePath, name string) (string, bool) { + if resolved, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = resolved + } + dir := filepath.Dir(exePath) + for _, cand := range []string{filepath.Join(dir, "bin", name), filepath.Join(dir, name)} { + if statExists(cand) { + return cand, true + } + } + return "", false +} + +func statExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Run ports searchAndReplace (search-and-replace.ts:114) minus prompts and +// telemetry (caller's job): determine replacements, wire input/output +// files, stream input → go-search-replace → (optional mydumper fix) → +// output. +func Run(fileName string, pairs []string, opts Options) (*Result, error) { + // Node: if (!pairs.length) throw (ts:138) + if len(pairs) == 0 { + return nil, errors.New("No search and replace parameters provided.") + } + details, err := GetSqlDumpDetails(fileName) + if err != nil { + return nil, err + } + + // Node: pairs.flatMap(pair => pair.split(',').map(trim)) (ts:148) + var replacements []string + for _, pair := range pairs { + for _, part := range strings.Split(pair, ",") { + replacements = append(replacements, strings.TrimSpace(part)) + } + } + + inputPath := fileName + outputPath := opts.Output + if opts.InPlace { + // Node copies the input to a temp "midput" file first (ts:40-58) because + // it opens a write stream on the original immediately. We instead stage + // the result in a sibling temp file and rename it into place only on + // success (see below), so the original is never truncated and can be + // read directly — no full extra copy of a multi-GB dump. + outputPath = fileName + } else if outputPath == "" { + // Default: temp output file keeping the basename (ts:79-90). + tmpDir, err := os.MkdirTemp("", "vip-search-replace") + if err != nil { + return nil, err + } + outputPath = filepath.Join(tmpDir, filepath.Base(fileName)) + } + + bin, err := ResolveBinary() + if err != nil { + return nil, err + } + + in, err := os.Open(inputPath) // #nosec G304 + if err != nil { + return nil, err + } + defer in.Close() + + // Stage the result in a temp file beside the target and rename it into place + // only after go-search-replace exits cleanly. Opening the target directly + // (os.Create) truncates it before the child's result is known, so a rejected + // search-replace pair left the user with a 0-byte file — and under + // --in-place that file is their own dump (parity blocker B2). The temp sits + // in the target's directory so the rename is same-filesystem, hence atomic; + // every failure path below removes it. + tmpPath, out, err := createTempBeside(outputPath) + if err != nil { + return nil, err + } + committed := false + defer func() { + if !committed { + _ = out.Close() + _ = os.Remove(tmpPath) + } + }() + + cmd := exec.Command(bin, replacements...) // #nosec G204 -- resolved binary + user-supplied pairs + cmd.Stdin = in + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + if details.Type == DumpTypeMyDumper { + err = pipeFixingMyDumper(stdout, out) + } else { + _, err = io.Copy(out, stdout) + } + if err != nil { + _ = cmd.Wait() + return nil, fmt.Errorf("couldn't write to the output file: %w", err) + } + if err := cmd.Wait(); err != nil { + return nil, err + } + if err := out.Close(); err != nil { + return nil, err + } + if err := os.Rename(tmpPath, outputPath); err != nil { + return nil, err + } + committed = true + + return &Result{InputFileName: fileName, OutputFileName: outputPath}, nil +} + +// createTempBeside opens a uniquely named temp file in target's directory, so a +// later os.Rename onto target stays on one filesystem (cross-device renames +// fail). When target already exists the temp is chmod'ed to match it, so an +// atomic replace never silently widens or narrows the file's permissions; for a +// new target the 0666 open mode reproduces os.Create's umask-respecting default. +func createTempBeside(target string) (string, *os.File, error) { + dir := filepath.Dir(target) + perm, hadTarget := os.FileMode(0), false + if st, err := os.Stat(target); err == nil { + perm, hadTarget = st.Mode().Perm(), true + } + for attempt := 0; attempt < 100; attempt++ { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", nil, err + } + p := filepath.Join(dir, "."+filepath.Base(target)+".vip-sr-"+hex.EncodeToString(buf[:])) + f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666) // #nosec G304 + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", nil, err + } + if hadTarget { + if err := f.Chmod(perm); err != nil { + _ = f.Close() + _ = os.Remove(p) + return "", nil, err + } + } + return p, f, nil + } + return "", nil, errors.New("unable to create a temporary file next to " + target) +} + +// pipeFixingMyDumper streams r to w applying FixMyDumperLine per line — +// Node's fixMyDumperTransform stage in the pipeline (ts:184). +func pipeFixingMyDumper(r io.Reader, w io.Writer) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + bw := bufio.NewWriter(w) + for scanner.Scan() { + if _, err := bw.WriteString(FixMyDumperLine(scanner.Text()) + "\n"); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return err + } + return bw.Flush() +} diff --git a/internal/searchreplace/searchreplace_test.go b/internal/searchreplace/searchreplace_test.go new file mode 100644 index 000000000..96343905a --- /dev/null +++ b/internal/searchreplace/searchreplace_test.go @@ -0,0 +1,250 @@ +package searchreplace + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// When vip-next is invoked via a symlink (e.g. ~/.local/bin/vip-next -> +// repo/bin/vip-next), os.Executable() returns the symlink path on macOS, so the +// bundled go-search-replace next to the REAL binary must still be found. +func TestLookupBundledFollowsSymlink(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "repo", "bin") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "vip-next"), []byte("x"), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "go-search-replace"), []byte("x"), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + linkDir := filepath.Join(root, "link") + if err := os.MkdirAll(linkDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(linkDir, "vip-next") + if err := os.Symlink(filepath.Join(realDir, "vip-next"), link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + got, ok := lookupBundled(link, "go-search-replace") + if !ok { + t.Fatal("expected to resolve sibling go-search-replace via the symlink's real dir") + } + if filepath.Base(got) != "go-search-replace" || !statExists(got) { + t.Fatalf("lookupBundled returned %q", got) + } +} + +// fakeBinary writes a script that upper-cases stdin (stand-in for +// go-search-replace; we assert plumbing, not replacement logic). +func fakeBinary(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake binary script is POSIX-only") + } + dir := t.TempDir() + p := filepath.Join(dir, "go-search-replace") + script := "#!/bin/sh\ntr 'a-z' 'A-Z'\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { // #nosec G306 -- executable test script + t.Fatal(err) + } + return p +} + +func TestResolveBinaryEnvVarFirst(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + got, err := ResolveBinary() + if err != nil { + t.Fatal(err) + } + if got != bin { + t.Errorf("got %q want %q", got, bin) + } +} + +func TestResolveBinaryFromPath(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", "") + t.Setenv("PATH", filepath.Dir(bin)) + got, err := ResolveBinary() + if err != nil { + t.Fatal(err) + } + if got != bin { + t.Errorf("got %q want %q", got, bin) + } +} + +func TestResolveBinaryMissing(t *testing.T) { + t.Setenv("VIP_SEARCH_REPLACE_BIN", "") + t.Setenv("PATH", t.TempDir()) // nothing on PATH + if _, err := ResolveBinary(); err == nil { + t.Error("want error when binary is nowhere") + } +} + +func TestRunToOutputFile(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "hello world\n") + out := filepath.Join(t.TempDir(), "out.sql") + + res, err := Run(in, []string{"from,to"}, Options{Output: out}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName != out { + t.Errorf("OutputFileName = %q", res.OutputFileName) + } + got, _ := os.ReadFile(out) // #nosec G304 + if strings.TrimSpace(string(got)) != "HELLO WORLD" { + t.Errorf("output = %q", got) + } + if res.UsingStdOut { + t.Error("UsingStdOut should be false") + } +} + +func TestRunInPlace(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "abc\n") + + res, err := Run(in, []string{"a,b"}, Options{InPlace: true}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName != in { + t.Errorf("in-place must overwrite the input; got %q", res.OutputFileName) + } + got, _ := os.ReadFile(in) // #nosec G304 + if strings.TrimSpace(string(got)) != "ABC" { + t.Errorf("content = %q", got) + } +} + +func TestRunDefaultTempOutput(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "q\n") + + res, err := Run(in, []string{"x,y"}, Options{}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName == "" || res.OutputFileName == in { + t.Errorf("default mode must write a temp copy, got %q", res.OutputFileName) + } + if filepath.Base(res.OutputFileName) != "in.sql" { + t.Errorf("temp file keeps the basename; got %q", res.OutputFileName) + } +} + +func TestRunMyDumperFixApplied(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "-- metadata.header 1\n-- mydb-schema-create.sql 0\n-- wp_posts 123\n") + out := filepath.Join(t.TempDir(), "out.sql") + + if _, err := Run(in, []string{"a,b"}, Options{Output: out}); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(out) // #nosec G304 + // tr upper-cases first, then the mydumper fix runs on the binary's + // output: "-- WP_POSTS 123" matches the rewrite pattern. + if !strings.Contains(string(got), "-- WP_POSTS -1") { + t.Errorf("mydumper fix not applied: %q", got) + } +} + +func TestRunNoPairs(t *testing.T) { + in := write(t, "in.sql", "q\n") + if _, err := Run(in, nil, Options{}); err == nil || + err.Error() != "No search and replace parameters provided." { + t.Errorf("err = %v", err) + } +} + +// failingBinary stands in for go-search-replace rejecting a search-replace +// pair: it writes nothing to stdout and exits non-zero. +func failingBinary(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake binary script is POSIX-only") + } + dir := t.TempDir() + p := filepath.Join(dir, "go-search-replace") + script := "#!/bin/sh\necho 'invalid search-replace pair' >&2\nexit 1\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { // #nosec G306 -- executable test script + t.Fatal(err) + } + return p +} + +// Regression for parity blocker B2: `--in-place` used to os.Create() the user's +// own file, truncating it BEFORE the child's result was known, so a rejected +// pair left a 0-byte file. Asserting the exit code alone would not catch this — +// assert the original bytes survive. +func TestRunInPlaceKeepsOriginalBytesWhenChildFails(t *testing.T) { + bin := failingBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + const original = "CREATE TABLE a;\n" + in := write(t, "in.sql", original) + + if _, err := Run(in, []string{"from,to"}, Options{InPlace: true}); err == nil { + t.Fatal("expected an error when go-search-replace fails") + } + + got, err := os.ReadFile(in) // #nosec G304 + if err != nil { + t.Fatalf("in-place input file must still exist after a failure: %v", err) + } + if string(got) != original { + t.Errorf("in-place input was destroyed by a failed run:\n got %q\nwant %q", got, original) + } +} + +// A failed run to an explicit --output must not leave a truncated artifact +// behind that a later step could mistake for a real dump. +func TestRunOutputFileNotLeftTruncatedWhenChildFails(t *testing.T) { + bin := failingBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "CREATE TABLE a;\n") + out := filepath.Join(t.TempDir(), "out.sql") + + if _, err := Run(in, []string{"from,to"}, Options{Output: out}); err == nil { + t.Fatal("expected an error when go-search-replace fails") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + b, _ := os.ReadFile(out) // #nosec G304 + t.Errorf("failed run left an output file behind (%d bytes: %q)", len(b), b) + } +} + +// The atomic rename must not silently widen or narrow the file's permissions. +func TestRunInPlacePreservesFileMode(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "abc\n") + if err := os.Chmod(in, 0o640); err != nil { + t.Fatal(err) + } + + if _, err := Run(in, []string{"a,b"}, Options{InPlace: true}); err != nil { + t.Fatal(err) + } + st, err := os.Stat(in) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != 0o640 { + t.Errorf("mode = %o, want 640", got) + } +} diff --git a/internal/siteimport/siteimport.go b/internal/siteimport/siteimport.go new file mode 100644 index 000000000..5cece2d1b --- /dev/null +++ b/internal/siteimport/siteimport.go @@ -0,0 +1,18 @@ +// Package siteimport ports src/lib/site-import/** plus the site-type / +// multisite-domain validations that gate `vip import sql`. +package siteimport + +const gbInBytes = int64(1024 * 1024 * 1024) + +// Node src/lib/site-import/db-file-import.ts:5-6. +const ( + SQLImportFileSizeLimit = 200 * gbInBytes + SQLImportFileSizeLimitLaunched = 10 * gbInBytes +) + +// databaseApplicationTypeIDs — src/lib/constants/vipgo.ts:19 +// [WORDPRESS=2, WORDPRESS_NON_PROD=6, NODEJS_MYSQL=5, NODEJS_MYSQL_REDIS=8]. +var databaseApplicationTypeIDs = map[int64]bool{2: true, 6: true, 5: true, 8: true} + +// IsSupportedApp ports isSupportedApp (db-file-import.ts:25). +func IsSupportedApp(typeID int64) bool { return databaseApplicationTypeIDs[typeID] } diff --git a/internal/siteimport/siteimport_test.go b/internal/siteimport/siteimport_test.go new file mode 100644 index 000000000..47d15ce09 --- /dev/null +++ b/internal/siteimport/siteimport_test.go @@ -0,0 +1,19 @@ +package siteimport + +import "testing" + +func TestIsSupportedApp(t *testing.T) { + // DATABASE_APPLICATION_TYPE_IDS = [2, 6, 5, 8] (src/lib/constants/vipgo.ts:19) + for id, want := range map[int64]bool{2: true, 6: true, 5: true, 8: true, 3: false, 0: false} { + if got := IsSupportedApp(id); got != want { + t.Errorf("IsSupportedApp(%d) = %v", id, got) + } + } +} + +func TestSizeLimits(t *testing.T) { + const gb = int64(1024 * 1024 * 1024) + if SQLImportFileSizeLimit != 200*gb || SQLImportFileSizeLimitLaunched != 10*gb { + t.Errorf("limits = %d / %d", SQLImportFileSizeLimit, SQLImportFileSizeLimitLaunched) + } +} diff --git a/internal/siteimport/sitetype.go b/internal/siteimport/sitetype.go new file mode 100644 index 000000000..2f02ff607 --- /dev/null +++ b/internal/siteimport/sitetype.go @@ -0,0 +1,74 @@ +package siteimport + +import ( + "regexp" + "strings" +) + +// MultilineCapture ports getMultilineStatement (validations/utils.ts:7): +// captures statements that start with a line matching the pattern and +// run until a line ending in ';'. +type MultilineCapture struct { + re *regexp.Regexp + capturing bool + statements [][]string +} + +// NewMultilineCapture compiles a literal start-of-statement pattern (the +// only caller uses "INSERT INTO `wp_site`" — site-type.ts:18). +func NewMultilineCapture(pattern string) *MultilineCapture { + return &MultilineCapture{re: regexp.MustCompile(regexp.QuoteMeta(pattern))} +} + +// Feed processes one line and returns the statements captured so far. +// Each statement is the list of its lines, like Node's string[][]. +func (m *MultilineCapture) Feed(line string) [][]string { + start := m.re.MatchString(line) + end := (start || m.capturing) && strings.HasSuffix(line, ";") + if start { + m.capturing = true + m.statements = append(m.statements, nil) + } + if m.capturing { + idx := len(m.statements) - 1 + m.statements[idx] = append(m.statements[idx], line) + } + if end { + m.capturing = false + } + return m.statements +} + +var ( + // SQL_WP_SITE_DOMAINS_REGEX — is-multisite-domain-mapped.ts:23. + wpSiteDomainsRE = regexp.MustCompile(`\(1,'([^']+)'`) + whitespaceRE = regexp.MustCompile(`\s`) +) + +// GetPrimaryDomainFromSQL ports getPrimaryDomainFromSQL +// (is-multisite-domain-mapped.ts:18): extract the domain of blog ID 1 +// from the first captured INSERT INTO `wp_site` statement. +func GetPrimaryDomainFromSQL(statements [][]string) string { + if len(statements) == 0 { + return "" + } + normalized := whitespaceRE.ReplaceAllString(strings.Join(statements[0], ""), "") + if m := wpSiteDomainsRE.FindStringSubmatch(normalized); m != nil { + return m[1] + } + return "" +} + +// MaybeSearchReplacePrimaryDomain ports maybeSearchReplacePrimaryDomain +// (is-multisite-domain-mapped.ts:36). NOTE: Node splits on ',' WITHOUT +// trimming here (unlike the replacement list built for the binary) — +// kept bug-for-bug. +func MaybeSearchReplacePrimaryDomain(domain string, searchReplace []string) string { + for _, pair := range searchReplace { + parts := strings.Split(pair, ",") + if len(parts) >= 2 && parts[0] == domain { + return parts[1] + } + } + return domain +} diff --git a/internal/siteimport/sitetype_test.go b/internal/siteimport/sitetype_test.go new file mode 100644 index 000000000..6db466321 --- /dev/null +++ b/internal/siteimport/sitetype_test.go @@ -0,0 +1,62 @@ +package siteimport + +import "testing" + +func TestMultilineStatementCapture(t *testing.T) { + cap := NewMultilineCapture("INSERT INTO `wp_site`") + lines := []string{ + "CREATE TABLE `wp_site2` (id INT);", + "INSERT INTO `wp_site` (id, domain) VALUES", + "(1,'example.com','/');", + "SELECT 1;", + } + var stmts [][]string + for _, l := range lines { + stmts = cap.Feed(l) + } + if len(stmts) != 1 || len(stmts[0]) != 2 { + t.Fatalf("stmts = %v", stmts) + } +} + +func TestMultilineStatementCaptureSingleLine(t *testing.T) { + cap := NewMultilineCapture("INSERT INTO `wp_site`") + stmts := cap.Feed("INSERT INTO `wp_site` VALUES (1,'a.com','/');") + if len(stmts) != 1 || len(stmts[0]) != 1 { + t.Fatalf("stmts = %v", stmts) + } + // a second statement opens a new capture slot + stmts = cap.Feed("INSERT INTO `wp_site` VALUES (2,'b.com','/');") + if len(stmts) != 2 { + t.Fatalf("stmts = %v", stmts) + } +} + +func TestGetPrimaryDomainFromSQL(t *testing.T) { + stmts := [][]string{{ + "INSERT INTO `wp_site` (id, domain, path) VALUES", + "(1,'multisite.example.com','/');", + }} + if got := GetPrimaryDomainFromSQL(stmts); got != "multisite.example.com" { + t.Errorf("domain = %q", got) + } + if got := GetPrimaryDomainFromSQL(nil); got != "" { + t.Errorf("empty stmts should give %q, got %q", "", got) + } +} + +func TestMaybeSearchReplacePrimaryDomain(t *testing.T) { + got := MaybeSearchReplacePrimaryDomain("old.example.com", + []string{"other.com,new-other.com", "old.example.com,new.example.com"}) + if got != "new.example.com" { + t.Errorf("got %q", got) + } + if got := MaybeSearchReplacePrimaryDomain("keep.com", nil); got != "keep.com" { + t.Errorf("got %q", got) + } + // Node does NOT trim around the comma in this path — bug-for-bug. + got = MaybeSearchReplacePrimaryDomain("a.com", []string{"a.com, b.com"}) + if got != " b.com" { + t.Errorf("untrimmed replacement expected, got %q", got) + } +} diff --git a/internal/siteimport/status.go b/internal/siteimport/status.go new file mode 100644 index 000000000..3f0d6a2b0 --- /dev/null +++ b/internal/siteimport/status.go @@ -0,0 +1,271 @@ +package siteimport + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — IMPORT_SQL_PROGRESS_POLL_INTERVAL (status.ts:25). +const DefaultPollInterval = 5 * time.Second + +// JobStep is one step of the import job as reported by the server +// (jobs[].progress.steps or synthesized from importStatus.progress). +type JobStep struct { + ID string + Name string + Status tui.StepState +} + +// ImportJob mirrors the slice of Job the poller consumes (real k8s job or +// the pseudo-job Node synthesizes from importStatus.progress — +// status.ts:288-328; the synthesis lives in the command's fetch closure). +type ImportJob struct { + CreatedAt string + CompletedAt string + Status string // progress.status; "" treated as "unknown" (status.ts:333) + Steps []JobStep +} + +// FailedStep is a failed entry from importStatus.progress.steps +// (status.ts:361 failedImportStep). +type FailedStep struct { + Name string + Output []string + StartedAt int64 // unix seconds +} + +// ProgressSnapshot flattens one ImportSQLProgress response. Job == nil +// means "no job data available yet" — the poller waits (or fast-returns +// under ReturnMissingJobImmediately). +type ProgressSnapshot struct { + Job *ImportJob + StatusProgressStartedAt int64 // importStatus.progress.started_at (unix seconds) + FailedStep *FailedStep + Launched bool +} + +// ProgressFetch retrieves the current snapshot (the command wraps +// gql.ImportSQLProgress). +type ProgressFetch func(ctx context.Context) (*ProgressSnapshot, error) + +// CheckStatusOpts configures CheckStatus. +type CheckStatusOpts struct { + Fetch ProgressFetch + Tracker *tui.ProgressTracker + Interval time.Duration + // ReturnMissingJobImmediately — true for `vip import sql status` + // (status.ts:198). + ReturnMissingJobImmediately bool + // OnPoll fires after each snapshot is applied to the tracker, before + // terminal-state checks — the command renders its suffix block here. + OnPoll func(createdAt, completedAt, overallStatus string) +} + +// StatusResult is the terminal outcome of a finished (non-failed) poll. +type StatusResult struct { + Status string + Message string // e.g. "No import job found" + CreatedAt string + CompletedAt string +} + +// ImportFailedError ports ImportFailedError (status.ts:107). +type ImportFailedError struct { + InImportProgress bool + CommandOutput []string + ErrorText string + StepName string + Launched bool +} + +func (e *ImportFailedError) Error() string { return e.ErrorText } + +// parseFlexibleTime mimics JS `new Date(s).getTime()`: accept the formats +// the API and the synthesis path produce. Returns ok=false for NaN cases. +func parseFlexibleTime(s string) (time.Time, bool) { + for _, layout := range []string{ + time.RFC3339, time.RFC1123, time.RFC1123Z, time.RFC822, time.RFC850, + "2006-01-02 15:04:05", "2006-01-02T15:04:05.000Z", + } { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// CheckStatus ports importSqlCheckStatus's getResults loop +// (status.ts:267-417). The command owns rendering and exit codes; this +// owns the poll-state machine. +func CheckStatus(ctx context.Context, opts CheckStatusOpts) (*StatusResult, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + overall := "Checking..." // status.ts:213 + + for { + snap, err := opts.Fetch(ctx) + if err != nil { + return nil, err + } + + if snap.Job == nil { + if opts.ReturnMissingJobImmediately { + // status.ts:329 — resolve('No import job found') + return &StatusResult{Message: "No import job found"}, nil + } + // status.ts:294 — progress meta not filled out yet; wait. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + continue + } + + job := snap.Job + jobStatus := job.Status + if jobStatus == "" { + jobStatus = "unknown" // status.ts:333 + } + createdAt := job.CreatedAt + completedAt := job.CompletedAt + + // failedImportStep gate (status.ts:353-366): the import_progress + // meta is only pertinent when it started at/after job creation. + var failed *FailedStep + if jobCreation, ok := parseFlexibleTime(createdAt); ok && + snap.StatusProgressStartedAt*1000 >= jobCreation.UnixMilli() { + if fs := snap.FailedStep; fs != nil && fs.StartedAt*1000 > jobCreation.UnixMilli() { + failed = fs + } + } + + if len(job.Steps) == 0 { + // status.ts:368 — reject({error: 'Could not enumerate the + // import job steps'}) + return nil, errors.New("Could not enumerate the import job steps") + } + + if failed != nil { + // status.ts:373 — demote the 'import' step to failed, render, + // then reject with the structured error. + steps := make([]JobStep, len(job.Steps)) + copy(steps, job.Steps) + for i := range steps { + if steps[i].ID == "import" { + steps[i].Status = tui.StepFailed + } + } + opts.Tracker.SetStepsFromServer(toServerSteps(steps)) + if opts.OnPoll != nil { + opts.OnPoll(createdAt, completedAt, "failed") + } + return nil, &ImportFailedError{ + InImportProgress: true, + CommandOutput: failed.Output, + ErrorText: "Import step failed", + StepName: failed.Name, + Launched: snap.Launched, + } + } + + opts.Tracker.SetStepsFromServer(toServerSteps(job.Steps)) + if opts.OnPoll != nil { + opts.OnPoll(createdAt, completedAt, overall) + } + + if jobStatus == "error" { + // status.ts:399 — reject({error: 'Import job failed', ...}) + return nil, errors.New("Import job failed") + } + + if jobStatus != "running" && completedAt != "" { + // status.ts:404 — resolve(importJob) + return &StatusResult{ + Status: jobStatus, CreatedAt: createdAt, CompletedAt: completedAt, + }, nil + } + + overall = "running" // status.ts:408 + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + } +} + +func toServerSteps(steps []JobStep) []tui.ServerStep { + out := make([]tui.ServerStep, len(steps)) + for i, s := range steps { + out[i] = tui.ServerStep{Name: s.Name, Status: s.Status} + } + return out +} + +// GetErrorMessage ports getErrorMessage (status.ts:116). Message blocks +// are copied verbatim, including blank lines and the conditional +// rollback notice (suppressed for launched environments). +func GetErrorMessage(fe *ImportFailedError) string { + rollbackMessage := "" + if !fe.Launched { + rollbackMessage = "Your site is " + color.BlueString("automatically being rolled back") + + " to the last backup prior to your import job.\n" + } + + message := fe.ErrorText + if !fe.InImportProgress { + return message + } + + commandOutputBlock := func() string { + if fe.CommandOutput != nil { + joined := strings.Join(fe.CommandOutput, ";") + return "\nPlease inspect your input file and make the appropriate corrections before trying again.\nThe server said:\n> " + + color.RedString(joined) + "\n" + } + return "" + } + + switch fe.StepName { + case "import_preflights": + message += "\nThis error occurred prior to the mysql batch script processing of your SQL file.\n\nYour site content was not altered.\n\nIf this error persists, please contact support.\n" + case "importing_db": + message += "\nThis error occurred during the mysql batch script processing of your SQL file.\n\n" + rollbackMessage + if fe.CommandOutput != nil { + message += commandOutputBlock() + } else { + message += "Please contact support and include this message along with your sql file." + } + case "validating_db": + message += "\nThis error occurred during the post-import validation of the imported data.\n\n" + rollbackMessage + "\n" + if fe.CommandOutput != nil { + message += commandOutputBlock() + } else { + message += "Please contact support and include this message along with your sql file." + } + case "update_primary_domain": + message += "\nThis error occurred during the update of the primary domain.\n\n" + rollbackMessage + "\n" + if fe.CommandOutput != nil { + message += commandOutputBlock() + } + } + return message +} + +// Capitalize ports format.ts capitalize (format.ts:139). +func Capitalize(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/siteimport/status_test.go b/internal/siteimport/status_test.go new file mode 100644 index 000000000..2b041d44a --- /dev/null +++ b/internal/siteimport/status_test.go @@ -0,0 +1,234 @@ +package siteimport + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/tui" +) + +// scriptedFetch returns each snapshot in order, repeating the last. +func scriptedFetch(snaps []ProgressSnapshot) ProgressFetch { + i := 0 + return func(ctx context.Context) (*ProgressSnapshot, error) { + s := snaps[i] + if i < len(snaps)-1 { + i++ + } + return &s, nil + } +} + +func TestCheckStatusSuccessFromJob(t *testing.T) { + created := "Mon, 01 Jun 2026 00:00:00 UTC" + completed := "Mon, 01 Jun 2026 00:05:00 UTC" + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: created, Status: "running", Steps: []JobStep{ + {ID: "preflights", Name: "Import preflights", Status: tui.StepSuccess}, + {ID: "import", Name: "Importing db", Status: tui.StepRunning}, + }}}, + {Job: &ImportJob{CreatedAt: created, CompletedAt: completed, Status: "success", Steps: []JobStep{ + {ID: "preflights", Name: "Import preflights", Status: tui.StepSuccess}, + {ID: "import", Name: "Importing db", Status: tui.StepSuccess}, + }}}, + } + pt := tui.NewProgressTracker(nil) + var polls int + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + OnPoll: func(_, _, _ string) { polls++ }, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" || res.CompletedAt != completed { + t.Errorf("res = %+v", res) + } + if polls < 2 { + t.Errorf("OnPoll fired %d times, want >= 2", polls) + } + if !pt.AllStepsSucceeded() { + t.Error("tracker should reflect all-success server steps") + } +} + +func TestCheckStatusJobErrorRejects(t *testing.T) { + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: "Mon, 01 Jun 2026 00:00:00 UTC", Status: "error", Steps: []JobStep{ + {ID: "import", Name: "Importing db", Status: tui.StepFailed}, + }}}, + } + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err == nil || !strings.Contains(err.Error(), "Import job failed") { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusMissingJobReturnsFast(t *testing.T) { + snaps := []ProgressSnapshot{{Job: nil}} + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + ReturnMissingJobImmediately: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Message != "No import job found" { + t.Errorf("message = %q", res.Message) + } +} + +func TestCheckStatusWaitsForProgressMeta(t *testing.T) { + created := "Mon, 01 Jun 2026 00:00:00 UTC" + snaps := []ProgressSnapshot{ + {Job: nil}, // meta not ready yet — must wait, not error + {Job: &ImportJob{CreatedAt: created, CompletedAt: created, Status: "success", Steps: []JobStep{ + {ID: "import", Name: "Importing db", Status: tui.StepSuccess}, + }}}, + } + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" { + t.Errorf("res = %+v", res) + } +} + +func TestCheckStatusEmptyStepsErrors(t *testing.T) { + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: "Mon, 01 Jun 2026 00:00:00 UTC", Status: "running"}}, + } + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Could not enumerate the import job steps" { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusFailedImportStepProducesStepError(t *testing.T) { + now := time.Now() + snaps := []ProgressSnapshot{{ + Job: &ImportJob{ + CreatedAt: now.Add(-time.Hour).UTC().Format(time.RFC1123), + Status: "running", + Steps: []JobStep{ + {ID: "import", Name: "Import", Status: tui.StepRunning}, + }, + }, + StatusProgressStartedAt: now.Unix(), + FailedStep: &FailedStep{ + Name: "importing_db", Output: []string{"ERROR 1064 (42000) at line 9"}, + StartedAt: now.Unix(), + }, + Launched: false, + }} + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + var fe *ImportFailedError + if !errors.As(err, &fe) { + t.Fatalf("err = %v (type %T)", err, err) + } + if fe.StepName != "importing_db" || len(fe.CommandOutput) != 1 || !fe.InImportProgress { + t.Errorf("fe = %+v", fe) + } + // The demoted server step renders the failed glyph (Node sets + // hasFailure only for caller steps; server-step failure shows via the + // glyph — progress.ts:264). + if frame := pt.Frame(); !strings.Contains(frame, "✕") { + t.Errorf("frame missing failed glyph: %q", frame) + } +} + +func TestCheckStatusOldFailedStepIgnored(t *testing.T) { + // A failed step from BEFORE the job was created is stale and must be + // ignored (status.ts:353-366 timestamp gate). + now := time.Now() + created := now.UTC().Format(time.RFC1123) + snaps := []ProgressSnapshot{{ + Job: &ImportJob{ + CreatedAt: created, CompletedAt: created, Status: "success", + Steps: []JobStep{{ID: "import", Name: "Import", Status: tui.StepSuccess}}, + }, + StatusProgressStartedAt: now.Add(-2 * time.Hour).Unix(), + FailedStep: &FailedStep{ + Name: "importing_db", StartedAt: now.Add(-2 * time.Hour).Unix(), + }, + }} + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" { + t.Errorf("res = %+v", res) + } +} + +func TestGetErrorMessageBlocks(t *testing.T) { + fe := &ImportFailedError{ + InImportProgress: true, ErrorText: "Import step failed", + StepName: "importing_db", CommandOutput: []string{"line1", "line2"}, + Launched: false, + } + msg := GetErrorMessage(fe) + for _, want := range []string{ + "Import step failed", + "This error occurred during the mysql batch script processing of your SQL file.", + "automatically being rolled back", + "The server said:", + "line1;line2", + } { + if !strings.Contains(msg, want) { + t.Errorf("message missing %q:\n%s", want, msg) + } + } + + // launched suppresses the rollback notice + fe.Launched = true + if msg := GetErrorMessage(fe); strings.Contains(msg, "rolled back") { + t.Errorf("launched env must not mention rollback:\n%s", msg) + } + + // no command output → contact-support line + fe.CommandOutput = nil + if msg := GetErrorMessage(fe); !strings.Contains(msg, "Please contact support and include this message along with your sql file.") { + t.Errorf("missing contact-support fallback:\n%s", msg) + } + + // preflights block + fe2 := &ImportFailedError{InImportProgress: true, ErrorText: "Import step failed", StepName: "import_preflights"} + if msg := GetErrorMessage(fe2); !strings.Contains(msg, "Your site content was not altered.") { + t.Errorf("preflights block missing:\n%s", msg) + } + + // non-import-progress error returns the bare text + fe3 := &ImportFailedError{ErrorText: "Could not enumerate the import job steps"} + if msg := GetErrorMessage(fe3); msg != "Could not enumerate the import job steps" { + t.Errorf("msg = %q", msg) + } +} + +func TestCapitalize(t *testing.T) { + for in, want := range map[string]string{"": "", "import preflights": "Import preflights", "a": "A"} { + if got := Capitalize(in); got != want { + t.Errorf("Capitalize(%q) = %q", in, got) + } + } +} diff --git a/internal/slowlogsapi/slowlogsapi.go b/internal/slowlogsapi/slowlogsapi.go new file mode 100644 index 000000000..5678dc87b --- /dev/null +++ b/internal/slowlogsapi/slowlogsapi.go @@ -0,0 +1,176 @@ +// Package slowlogsapi wraps the GetAppSlowlogs genqlient operation behind +// a flat Go-friendly surface. The schema field is +// `AppEnvironment.slowlogs(limit, after)` and returns +// `AppEnvironmentSlowlogsList` (`nodes`, `nextCursor`, +// `pollingDelaySeconds`). +// +// Node parity: src/lib/app-slowlogs/app-slowlogs.ts (getRecentSlowlogs). +// The reflection walker matches internal/logsapi but yields a richer row +// shape: timestamp, rowsSent, rowsExamined, queryTime, requestUri, query. +package slowlogsapi + +import ( + "context" + "reflect" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// LIMIT_MAX is the server-side ceiling for the `limit` argument on the +// slowlogs query. Node's vip-slowlogs.ts uses 500 as the validation cap +// (vs 5000 for runtime logs); slowlogs are intentionally smaller-batched +// to keep the MySQL slow-query window manageable. +const LIMIT_MAX = 500 + +// SlowlogNode is one slow-query log line. All fields are strings on the +// wire (the schema exposes them as String, including rowsSent/rowsExamined +// which are numeric in MySQL but serialized as text to preserve bigint +// precision). +type SlowlogNode struct { + Timestamp string + RowsSent string + RowsExamined string + QueryTime string + RequestUri string + Query string +} + +// Page is a single response page from the slowlogs endpoint. +type Page struct { + Nodes []SlowlogNode + NextCursor *string + PollingDelaySeconds int +} + +// RecentSlowlogs runs GetAppSlowlogs and flattens the response. Validation +// (limit bounds, format allow-list) lives at the command-line layer to +// match Node's exact error wording. +func RecentSlowlogs(ctx context.Context, c graphql.Client, appID, envID int64, limit int, after *string) (*Page, error) { + resp, err := gql.GetAppSlowlogs(ctx, c, appID, envID, int64(limit), after) + if err != nil { + return nil, err + } + return reflectSlowlogsResponse(resp), nil +} + +// reflectSlowlogsResponse mirrors logsapi.reflectLogsResponse but pulls +// the six slowlog-specific fields from each node. Same defensive shape: +// returns an empty Page on any missing parent field. +func reflectSlowlogsResponse(v any) *Page { + p := &Page{Nodes: []SlowlogNode{}} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return p + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return p + } + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return p + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return p + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return p + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return p + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return p + } + sl := env.FieldByName("Slowlogs") + for sl.Kind() == reflect.Ptr { + if sl.IsNil() { + return p + } + sl = sl.Elem() + } + if !sl.IsValid() || sl.Kind() != reflect.Struct { + return p + } + if nc := sl.FieldByName("NextCursor"); nc.IsValid() { + switch nc.Kind() { + case reflect.Ptr: + if !nc.IsNil() { + s := nc.Elem().String() + p.NextCursor = &s + } + case reflect.String: + s := nc.String() + p.NextCursor = &s + } + } + if pd := sl.FieldByName("PollingDelaySeconds"); pd.IsValid() { + switch pd.Kind() { + case reflect.Ptr: + if !pd.IsNil() { + p.PollingDelaySeconds = int(pd.Elem().Int()) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.PollingDelaySeconds = int(pd.Int()) + } + } + nodes := sl.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return p + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item SlowlogNode + item.Timestamp = readStringField(n, "Timestamp") + item.RowsSent = readStringField(n, "RowsSent") + item.RowsExamined = readStringField(n, "RowsExamined") + item.QueryTime = readStringField(n, "QueryTime") + item.RequestUri = readStringField(n, "RequestUri") + item.Query = readStringField(n, "Query") + p.Nodes = append(p.Nodes, item) + } + return p +} + +// readStringField yields the string value of a struct field that may be +// either `string` or `*string` (genqlient emits either depending on +// nullability + use_struct_references). Missing or nil pointer fields +// return "". +func readStringField(rv reflect.Value, name string) string { + f := rv.FieldByName(name) + if !f.IsValid() { + return "" + } + switch f.Kind() { + case reflect.Ptr: + if f.IsNil() { + return "" + } + return f.Elem().String() + case reflect.String: + return f.String() + } + return "" +} diff --git a/internal/slowlogsapi/slowlogsapi_test.go b/internal/slowlogsapi/slowlogsapi_test.go new file mode 100644 index 000000000..6d43b3c23 --- /dev/null +++ b/internal/slowlogsapi/slowlogsapi_test.go @@ -0,0 +1,93 @@ +package slowlogsapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// slowlogsServer returns a stub /graphql endpoint that responds with the +// given JSON body for every request. Each RecentSlowlogs call fires one +// query — a constant body suffices. +func slowlogsServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestRecentSlowlogsHappyPath(t *testing.T) { + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","rowsSent":"10","rowsExamined":"1000","queryTime":"1.234","requestUri":"/wp-admin/edit.php","query":"SELECT * FROM wp_posts"},{"timestamp":"2024-01-01T00:00:01Z","rowsSent":"5","rowsExamined":"500","queryTime":"0.567","requestUri":"/wp-login.php","query":"SELECT * FROM wp_users"}],"nextCursor":"xyz","pollingDelaySeconds":60}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 2 { + t.Fatalf("Nodes len = %d, want 2 (page=%+v)", len(page.Nodes), page) + } + got := page.Nodes[0] + if got.Timestamp != "2024-01-01T00:00:00Z" { + t.Errorf("Nodes[0].Timestamp = %q", got.Timestamp) + } + if got.RowsSent != "10" || got.RowsExamined != "1000" || got.QueryTime != "1.234" { + t.Errorf("Nodes[0] numeric fields = (%q, %q, %q)", got.RowsSent, got.RowsExamined, got.QueryTime) + } + if got.RequestUri != "/wp-admin/edit.php" || got.Query != "SELECT * FROM wp_posts" { + t.Errorf("Nodes[0] string fields = (%q, %q)", got.RequestUri, got.Query) + } + if page.NextCursor == nil || *page.NextCursor != "xyz" { + t.Errorf("NextCursor = %v, want xyz", page.NextCursor) + } + if page.PollingDelaySeconds != 60 { + t.Errorf("PollingDelaySeconds = %d, want 60", page.PollingDelaySeconds) + } +} + +func TestRecentSlowlogsEmpty(t *testing.T) { + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 0 { + t.Errorf("Nodes len = %d, want 0; page=%+v", len(page.Nodes), page) + } + if page.NextCursor != nil { + t.Errorf("NextCursor = %v, want nil", page.NextCursor) + } + if page.PollingDelaySeconds != 30 { + t.Errorf("PollingDelaySeconds = %d, want 30", page.PollingDelaySeconds) + } +} + +func TestRecentSlowlogsNullFieldsAreEmptyStrings(t *testing.T) { + // Schema declares every node field as nullable String. A null on any + // field should surface as "" rather than panic on a nil pointer. + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[{"timestamp":"t","rowsSent":null,"rowsExamined":null,"queryTime":"0","requestUri":null,"query":"Q"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 1 { + t.Fatalf("Nodes len = %d, want 1", len(page.Nodes)) + } + got := page.Nodes[0] + if got.Timestamp != "t" || got.QueryTime != "0" || got.Query != "Q" { + t.Errorf("non-null fields lost: %+v", got) + } + if got.RowsSent != "" || got.RowsExamined != "" || got.RequestUri != "" { + t.Errorf("null fields should be empty strings, got %+v", got) + } +} diff --git a/internal/softwaresettings/format.go b/internal/softwaresettings/format.go new file mode 100644 index 000000000..dab913f0a --- /dev/null +++ b/internal/softwaresettings/format.go @@ -0,0 +1,192 @@ +// Package softwaresettings contains pure formatting logic for `vip config +// software get` output. It is decoupled from cobra so it can be unit-tested +// without bringing in the full command tree. +package softwaresettings + +import ( + "sort" + "strings" +) + +// ManagedOptionKey is the sentinel value for WordPress managed updates. +const ManagedOptionKey = "managed_latest" + +// Version is an available or current software version entry. +type Version struct { + Version string + Default bool + Deprecated bool + Unstable bool +} + +// Software holds all settings for one software component. +type Software struct { + Name, Slug string + Pinned bool + Current Version + Options []Version +} + +// FormattedRow is one row of `config software get` output. +type FormattedRow struct { + Name string + Slug string + Version string + AvailableVersions any // string (non-JSON, sorted+joined) or []string (JSON) +} + +// allOptionValues ports Node's _optionsForVersion (software.ts:167-208). +// The returned order is Node's allOptions array: +// +// managed (wordpress only) → supported (option-array order) → test +// (unstable) → deprecated +// +// Node keeps deprecated entries in this list; it is the DISPLAY path +// (formatSoftwareSettings, software.ts:439 `.filter(option => +// !option.deprecated)`) that removes them, not the validation path. +func allOptionValues(s Software) []string { + var supported, test, deprecated []string + for _, o := range s.Options { + switch { + case o.Deprecated: + deprecated = append(deprecated, o.Version) + case o.Unstable: + test = append(test, o.Version) + default: + supported = append(supported, o.Version) + } + } + var out []string + if s.Slug == "wordpress" { + out = append(out, ManagedOptionKey) + } + out = append(out, supported...) + out = append(out, test...) + out = append(out, deprecated...) + return out +} + +// optionValues is the DISPLAY subset: allOptionValues minus deprecated, +// matching formatSoftwareSettings' filter (software.ts:439). +func optionValues(s Software) []string { + deprecated := make(map[string]bool, len(s.Options)) + for _, o := range s.Options { + if o.Deprecated { + deprecated[o.Version] = true + } + } + all := allOptionValues(s) + out := make([]string, 0, len(all)) + for _, v := range all { + if !deprecated[v] { + out = append(out, v) + } + } + return out +} + +func baseRow(s Software) FormattedRow { + version := s.Current.Version + if s.Slug == "wordpress" && !s.Pinned { + version += " (managed updates)" // software.ts:428-430 + } + return FormattedRow{Name: s.Name, Slug: s.Slug, Version: version} +} + +// FormatSetting formats for non-JSON output (available_versions sorted + comma-joined). +func FormatSetting(s Software, includes []string, _ string) FormattedRow { + r := baseRow(s) + if contains(includes, "available_versions") { + vals := optionValues(s) + sort.Strings(vals) + r.AvailableVersions = strings.Join(vals, ",") + } + return r +} + +// FormatSettingJSON formats for JSON output (available_versions as unsorted []string). +func FormatSettingJSON(s Software, includes []string) FormattedRow { + r := baseRow(s) + if contains(includes, "available_versions") { + r.AvailableVersions = optionValues(s) + } + return r +} + +func contains(ss []string, v string) bool { + for _, s := range ss { + if s == v { + return true + } + } + return false +} + +// componentNames maps slug → display name, mirroring Node's +// getComponentDisplayName (software.ts). +var componentNames = map[string]string{ + "wordpress": "WordPress", + "php": "PHP", + "muplugins": "MU Plugins", + "nodejs": "Node.js", +} + +// ComponentDisplayName returns the human-readable name for a component slug. +func ComponentDisplayName(slug string) string { return componentNames[slug] } + +// ValidComponents mirrors _processComponent (software.ts:225): WordPress app +// types {2,6} → wordpress,php,muplugins ; Node.js {3,5,7,8} → nodejs. +func ValidComponents(appTypeID int64) []string { + switch appTypeID { + case 2, 6: + return []string{"wordpress", "php", "muplugins"} + case 3, 5, 7, 8: + return []string{"nodejs"} + default: + return nil + } +} + +// ValidationError carries a Node-parity user-facing message. +type ValidationError struct{ Msg string } + +func (e *ValidationError) Error() string { return e.Msg } + +// ResolveComponent validates a user-provided component against the app type. +func ResolveComponent(appTypeID int64, component string) (string, error) { + valid := ValidComponents(appTypeID) + if len(valid) == 0 { + return "", &ValidationError{"No components are supported for this application"} + } + if component == "" { + if len(valid) == 1 { + return valid[0], nil + } + return "", &ValidationError{"Please specify a component: " + strings.Join(valid, ",")} + } + if !contains(valid, component) { + return "", &ValidationError{"Component " + component + " is not supported. Use one of: " + strings.Join(valid, ",")} + } + return component, nil +} + +// AllowedVersions returns the version values shown by `config software get` +// (deprecated excluded, per software.ts:439). +func AllowedVersions(s Software) []string { return optionValues(s) } + +// UpdatableVersions returns the versions `config software update` accepts — +// Node's _optionsForVersion values, deprecated INCLUDED (software.ts:275-282). +// Deprecated builds are precisely what an incident responder rolls back to, +// so they must stay selectable even though they are hidden from `get`. +func UpdatableVersions(s Software) []string { return allOptionValues(s) } + +// ResolveVersion validates a user-provided version against the set Node's +// _processComponentVersion accepts (software.ts:275). The "Use one of:" list +// is built from the same values, so it advertises deprecated versions too. +func ResolveVersion(s Software, component, version string) (string, error) { + allowed := UpdatableVersions(s) + if !contains(allowed, version) { + return "", &ValidationError{"Version " + version + " is not supported for " + componentNames[component] + ". Use one of: " + strings.Join(allowed, ",")} + } + return version, nil +} diff --git a/internal/softwaresettings/format_test.go b/internal/softwaresettings/format_test.go new file mode 100644 index 000000000..853baee21 --- /dev/null +++ b/internal/softwaresettings/format_test.go @@ -0,0 +1,123 @@ +package softwaresettings + +import ( + "reflect" + "strings" + "testing" +) + +func wpSetting() Software { + return Software{ + Name: "WordPress", Slug: "wordpress", Pinned: false, + Current: Version{Version: "6.4"}, + Options: []Version{{Version: "6.3"}, {Version: "6.4"}, {Version: "6.5", Unstable: true}, {Version: "5.9", Deprecated: true}}, + } +} + +func TestFormatManagedUpdatesSuffix(t *testing.T) { + got := FormatSetting(wpSetting(), nil, "table") + if got.Version != "6.4 (managed updates)" { + t.Errorf("version = %q", got.Version) + } +} + +func TestFormatAvailableVersionsNonJSONSortedJoined(t *testing.T) { + got := FormatSetting(wpSetting(), []string{"available_versions"}, "table") + if got.AvailableVersions != "6.3,6.4,6.5,managed_latest" { + t.Errorf("available = %q", got.AvailableVersions) + } +} + +func TestFormatAvailableVersionsJSONArray(t *testing.T) { + got := FormatSettingJSON(wpSetting(), []string{"available_versions"}) + want := []string{"managed_latest", "6.3", "6.4", "6.5"} // managed → supported(option order) → test + if !reflect.DeepEqual(got.AvailableVersions, want) { + t.Errorf("available = %v want %v", got.AvailableVersions, want) + } +} + +func TestValidComponentsForAppType(t *testing.T) { + if got := ValidComponents(2); !reflect.DeepEqual(got, []string{"wordpress", "php", "muplugins"}) { + t.Errorf("wp components = %v", got) + } + if got := ValidComponents(6); !reflect.DeepEqual(got, []string{"wordpress", "php", "muplugins"}) { + t.Errorf("wp-nonprod components = %v", got) + } + if got := ValidComponents(3); !reflect.DeepEqual(got, []string{"nodejs"}) { + t.Errorf("node components = %v", got) + } +} + +func TestResolveComponentRejectsUnsupported(t *testing.T) { + _, err := ResolveComponent(2, "nodejs") + if err == nil || err.Error() != "Component nodejs is not supported. Use one of: wordpress,php,muplugins" { + t.Errorf("err = %v", err) + } +} + +func TestResolveVersionRejectsUnsupported(t *testing.T) { + _, err := ResolveVersion(wpSetting(), "wordpress", "9.9") + if err == nil || !strings.Contains(err.Error(), "Version 9.9 is not supported for WordPress. Use one of:") { + t.Errorf("err = %v", err) + } +} + +func TestResolveVersionAcceptsAllowed(t *testing.T) { + v, err := ResolveVersion(wpSetting(), "wordpress", "managed_latest") + if err != nil || v != "managed_latest" { + t.Errorf("v=%q err=%v", v, err) + } +} + +// Register 2.9. Node's _processComponentVersion (software.ts:275) validates +// against _optionsForVersion(), whose allOptions array is +// managed → supported → test → DEPRECATED (software.ts:204-209). Deprecated +// versions are therefore selectable for an update. Only the `config software +// get` display path filters them out (software.ts:439). Rejecting them in Go +// blocks the exact rollback a responder reaches for during an incident. +func TestResolveVersionAcceptsDeprecatedVersion(t *testing.T) { + v, err := ResolveVersion(wpSetting(), "wordpress", "5.9") + if err != nil { + t.Fatalf("ResolveVersion(5.9) = %v, want nil — Node permits deprecated versions for update", err) + } + if v != "5.9" { + t.Errorf("v = %q, want 5.9", v) + } +} + +// The "Use one of:" list Node prints is built from the same validValues, +// so it advertises deprecated versions too. +func TestResolveVersionErrorListsDeprecatedVersions(t *testing.T) { + _, err := ResolveVersion(wpSetting(), "wordpress", "9.9") + if err == nil { + t.Fatal("want error for 9.9") + } + if !strings.Contains(err.Error(), "5.9") { + t.Errorf("err = %q, want the deprecated 5.9 listed in the allowed set", err) + } +} + +// Node's option order — managed, supported (option-array order), test, +// deprecated last. +func TestUpdatableVersionsOrderMatchesNodeAllOptions(t *testing.T) { + got := UpdatableVersions(wpSetting()) + want := []string{"managed_latest", "6.3", "6.4", "6.5", "5.9"} + if !reflect.DeepEqual(got, want) { + t.Errorf("UpdatableVersions = %v, want %v", got, want) + } +} + +// Guard: broadening the UPDATE surface must not leak deprecated versions +// into `config software get`, which Node explicitly filters (software.ts:439). +func TestDisplayVersionsStillExcludeDeprecated(t *testing.T) { + got := FormatSettingJSON(wpSetting(), []string{"available_versions"}) + vals, ok := got.AvailableVersions.([]string) + if !ok { + t.Fatalf("AvailableVersions type = %T", got.AvailableVersions) + } + for _, v := range vals { + if v == "5.9" { + t.Errorf("deprecated 5.9 leaked into `config software get` output: %v", vals) + } + } +} diff --git a/internal/sqlexport/diskspace.go b/internal/sqlexport/diskspace.go new file mode 100644 index 000000000..9b44ff787 --- /dev/null +++ b/internal/sqlexport/diskspace.go @@ -0,0 +1,36 @@ +package sqlexport + +import ( + "fmt" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// VipDataPath is the directory whose free space the storage check +// inspects (backup-storage-availability.ts:40: path.join(xdgData(), 'vip')). +func VipDataPath() string { return filepath.Join(paths.XDGData(), "vip") } + +// ConfirmEnoughStorage ports +// validateAndPromptDiskSpaceWarningForBackupImport +// (backup-storage-availability.ts:84): when free space at the vip data +// path exceeds the archive size, continue silently; otherwise prompt. +// freeBytes and confirm are injected for tests; promptShown reports +// whether the user was asked (the command uses it to re-pad the +// progress frame, export-sql.ts:429-438). +func ConfirmEnoughStorage(archiveSize int64, freeBytes func() (int64, error), confirm func(message string) (bool, error)) (cont bool, promptShown bool, err error) { + free, err := freeBytes() + if err != nil { + return false, false, err + } + if free > archiveSize { + return true, false, nil + } + msg := fmt.Sprintf("We recommend that you have at least %s of free space in your machine to download this database backup. Do you still want to continue with downloading the database backup?", + FormatMetricBytes(archiveSize)) + ok, err := confirm(msg) + if err != nil { + return false, true, err + } + return ok, true, nil +} diff --git a/internal/sqlexport/diskspace_unix.go b/internal/sqlexport/diskspace_unix.go new file mode 100644 index 000000000..36087e2cf --- /dev/null +++ b/internal/sqlexport/diskspace_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package sqlexport + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// FreeBytesAt reports the free disk space available to the current user +// at path (the check-disk-space equivalent). The path is created if +// missing so Statfs has something to stat (the vip data dir may not +// exist on first run). +func FreeBytesAt(path string) (int64, error) { + if err := os.MkdirAll(path, 0o755); err != nil { + return 0, err + } + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return 0, err + } + return int64(st.Bavail) * int64(st.Bsize), nil // #nosec G115 -- disk sizes fit int64 +} diff --git a/internal/sqlexport/diskspace_windows.go b/internal/sqlexport/diskspace_windows.go new file mode 100644 index 000000000..125beb242 --- /dev/null +++ b/internal/sqlexport/diskspace_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package sqlexport + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// FreeBytesAt reports the free disk space available to the current user +// at path. +func FreeBytesAt(path string) (int64, error) { + if err := os.MkdirAll(path, 0o755); err != nil { + return 0, err + } + var freeBytesAvailable, totalBytes, totalFreeBytes uint64 + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + if err := windows.GetDiskFreeSpaceEx(p, &freeBytesAvailable, &totalBytes, &totalFreeBytes); err != nil { + return 0, err + } + return int64(freeBytesAvailable), nil // #nosec G115 -- disk sizes fit int64 +} diff --git a/internal/sqlexport/download.go b/internal/sqlexport/download.go new file mode 100644 index 000000000..eecbe2cc8 --- /dev/null +++ b/internal/sqlexport/download.go @@ -0,0 +1,75 @@ +package sqlexport + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// OnProgress receives (bytesDownloaded, totalBytes); totalBytes is -1 +// when the response has no Content-Length (download-file.ts:32). +type OnProgress func(downloaded, total int64) + +// DownloadFile ports lib/http/download-file.ts: stream url to +// destinationPath, reporting progress per chunk. On write failure the +// partial file is removed. +func DownloadFile(ctx context.Context, url, destinationPath string, onProgress OnProgress) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("Request to %s failed: %s", url, err.Error()) + } + // NOT http.DefaultClient: the export URL is presigned, so its query string + // is the credential. See internal/httpproxy. (Node's download-file.ts uses + // the global fetch, which proxies nothing at all; the divergence is that a + // user who set VIP_PROXY now gets the download proxied too — an opt-in they + // asked for, and the only alternative to leaking a signed URL to an + // unapproved proxy.) + resp, err := httpproxy.Client().Do(req) + if err != nil { + return fmt.Errorf("Request to %s failed: %s", url, err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + // download-file.ts:24 — "Status: <code> <statusText>". + return fmt.Errorf("Failed to download file. Status: %d %s", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + total := resp.ContentLength // -1 when missing, matching Node's null + + out, err := os.Create(destinationPath) // #nosec G304 -- user-chosen output path + if err != nil { + return fmt.Errorf("Failed to write file to disk: %s", err.Error()) + } + + var downloaded int64 + buf := make([]byte, 64*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := out.Write(buf[:n]); werr != nil { + out.Close() + _ = os.Remove(destinationPath) // download-file.ts:51 partial-file cleanup + return fmt.Errorf("Failed to write file to disk: %s", werr.Error()) + } + downloaded += int64(n) + if onProgress != nil { + onProgress(downloaded, total) + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + out.Close() + _ = os.Remove(destinationPath) + return fmt.Errorf("Failed to write file to disk: %s", rerr.Error()) + } + } + return out.Close() +} diff --git a/internal/sqlexport/download_test.go b/internal/sqlexport/download_test.go new file mode 100644 index 000000000..ac3a721fa --- /dev/null +++ b/internal/sqlexport/download_test.go @@ -0,0 +1,103 @@ +package sqlexport + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestFormatBytes(t *testing.T) { + for in, want := range map[int64]string{ + 0: "0 bytes", + 512: "512 bytes", + 1024: "1 KB", + 1536: "1.5 KB", + 1048576: "1 MB", + } { + if got := FormatBytes(in); got != want { + t.Errorf("FormatBytes(%d) = %q, want %q", in, got, want) + } + } + if got := FormatMetricBytes(1000); got != "1 KB" { + t.Errorf("FormatMetricBytes(1000) = %q", got) + } + if got := FormatMetricBytes(1500000000); got != "1.5 GB" { + t.Errorf("FormatMetricBytes(1.5GB) = %q", got) + } +} + +func TestDownloadFileHappyPath(t *testing.T) { + body := strings.Repeat("x", 200000) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Explicit Content-Length so the progress callback sees a total + // (large bodies otherwise go chunked in httptest). + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.sql.gz") + var lastDownloaded, lastTotal int64 + err := DownloadFile(context.Background(), srv.URL, dest, func(d, total int64) { + lastDownloaded, lastTotal = d, total + }) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) // #nosec G304 + if len(got) != len(body) { + t.Errorf("len = %d", len(got)) + } + if lastDownloaded != int64(len(body)) || lastTotal != int64(len(body)) { + t.Errorf("progress = %d/%d", lastDownloaded, lastTotal) + } +} + +func TestDownloadFileNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "gone", http.StatusNotFound) + })) + defer srv.Close() + err := DownloadFile(context.Background(), srv.URL, filepath.Join(t.TempDir(), "x"), nil) + // download-file.ts:24. + if err == nil || !strings.Contains(err.Error(), "Failed to download file. Status: 404 Not Found") { + t.Errorf("err = %v", err) + } +} + +func TestConfirmEnoughStorage(t *testing.T) { + t.Setenv("NO_COLOR", "1") + // plenty of space → no prompt + cont, shown, err := ConfirmEnoughStorage(10, + func() (int64, error) { return 1000, nil }, + func(string) (bool, error) { t.Fatal("must not prompt"); return false, nil }) + if err != nil || !cont || shown { + t.Errorf("cont=%v shown=%v err=%v", cont, shown, err) + } + // tight space → prompt with the recommendation message + var msg string + cont, shown, err = ConfirmEnoughStorage(2_000_000_000, + func() (int64, error) { return 10, nil }, + func(m string) (bool, error) { msg = m; return true, nil }) + if err != nil || !cont || !shown { + t.Errorf("cont=%v shown=%v err=%v", cont, shown, err) + } + if !strings.Contains(msg, "We recommend that you have at least 2 GB of free space in your machine to download this database backup.") { + t.Errorf("msg = %q", msg) + } +} + +func TestFreeBytesAt(t *testing.T) { + free, err := FreeBytesAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if free <= 0 { + t.Errorf("free = %d", free) + } +} diff --git a/internal/sqlexport/export.go b/internal/sqlexport/export.go new file mode 100644 index 000000000..a6c2f62d5 --- /dev/null +++ b/internal/sqlexport/export.go @@ -0,0 +1,321 @@ +package sqlexport + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/poll" + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — EXPORT_SQL_PROGRESS_POLL_INTERVAL (export-sql.ts:34). +const DefaultPollInterval = time.Second + +// DefaultPollTimeout is the ceiling export-sql.ts:547 and :555 inherit by +// calling pollUntil without a timeout: 6 hours (src/lib/utils.ts:18). +const DefaultPollTimeout = poll.DefaultTimeout + +// Step IDs (export-sql.ts:236). +const ( + StepPrepare = "prepare" + StepCreate = "create" + StepDownloadLink = "downloadLink" + StepConfirmEnoughStorage = "confirmEnoughStorage" + StepDownload = "download" +) + +// Steps returns the caller-step seed list for the export tracker +// (export-sql.ts:269-275). +func Steps() []tui.ProgressStep { + return []tui.ProgressStep{ + {ID: StepPrepare, Name: "Preparing for backup download"}, + {ID: StepCreate, Name: "Creating backup copy"}, + {ID: StepDownloadLink, Name: "Requesting download link"}, + {ID: StepConfirmEnoughStorage, Name: "Checking if there's enough storage"}, + {ID: StepDownload, Name: "Downloading file"}, + } +} + +// Backup flattens latestBackup (export-sql.ts:43-50). +type Backup struct { + ID int64 + SQLDumpTool string + CreatedAt string +} + +// ExportJob flattens the db_backup_copy job the workflow polls. +type ExportJob struct { + BackupID int64 // metadata[name=backupId] + UploadPath string // metadata[name=uploadPath] + BytesWritten string // metadata[name=bytesWritten] + StepStatus map[string]string +} + +// BackupAndJobs is one AppBackupAndJobStatus response. +type BackupAndJobs struct { + LatestBackup *Backup + Jobs []ExportJob + EnvSQLDumpTool string +} + +// Deps injects every side effect for tests. +type Deps struct { + FetchStatus func(ctx context.Context) (*BackupAndJobs, error) + CreateExport func(ctx context.Context, backupID int64) error + GenerateLink func(ctx context.Context, backupID int64) (string, error) + RunBackup func(ctx context.Context) error + StartLive func(ctx context.Context, cfg []byte) (string, error) + PollLiveURL func(ctx context.Context, copyID string) (url string, size int64, err error) + Confirm func(message string) (bool, error) + FreeBytes func() (int64, error) + Download func(ctx context.Context, url, dest string, onProgress OnProgress) error +} + +// Options mirror ExportSQLOptions + the env identifiers the messages need. +type Options struct { + OutputFile string + GenerateBackup bool + SkipDownload bool + LiveCopy *LiveCopyCLIOptions + Interval time.Duration + // Timeout caps each export-job poll. Zero means DefaultPollTimeout. + Timeout time.Duration + AppID int64 + AppName string + EnvUniqueLabel string +} + +// exportJobFor finds the job whose backupId metadata matches the latest +// backup (export-sql.ts:296-299). +func exportJobFor(st *BackupAndJobs) *ExportJob { + if st == nil || st.LatestBackup == nil { + return nil + } + for i := range st.Jobs { + if st.Jobs[i].BackupID == st.LatestBackup.ID { + return &st.Jobs[i] + } + } + return nil +} + +// Run ports ExportSQLCommand.run (export-sql.ts:375). It returns the path of +// the saved file, or "" when nothing was saved (SkipDownload, or an error). +// +// The "File saved to <path>" message is intentionally NOT printed here: the +// caller prints it AFTER stopping its progress renderer. Emitting it here — +// while the renderer is still animating the step list on stderr — writes a line +// to stdout that shifts the terminal cursor, so the renderer's final cursor-up +// undershoots and leaves a duplicated top line (the dev-env sync progress bug). +func Run(ctx context.Context, tracker *tui.ProgressTracker, opts Options, deps Deps, out io.Writer) (string, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + timeout := opts.Timeout + if timeout == 0 { + timeout = DefaultPollTimeout + } + + if opts.OutputFile != "" { + dir := filepath.Dir(opts.OutputFile) + if err := checkWritable(dir); err != nil { + return "", fmt.Errorf("Cannot write to the specified path: %s", err.Error()) + } + } + filename := opts.OutputFile + if filename == "" { + filename = "exported.sql.gz" // export-sql.ts:390 + } + + _ = tracker.StepRunning(StepPrepare) + + var url string + var size int64 + + if opts.LiveCopy != nil && opts.LiveCopy.UseLiveBackupCopy { + cfg, err := BuildConfig(opts.LiveCopy) + if err != nil { + return "", err + } + copyID, err := deps.StartLive(ctx, cfg) + if err != nil { + // export-sql.ts:612 wraps every live-copy failure. + return "", fmt.Errorf("Error creating live backup copy: %s", err.Error()) + } + _ = tracker.StepSuccess(StepPrepare) + _ = tracker.StepRunning(StepCreate) + liveURL, liveSize, err := deps.PollLiveURL(ctx, copyID) + if err != nil { + return "", fmt.Errorf("Error creating live backup copy: %s", err.Error()) + } + _ = tracker.StepSuccess(StepCreate) + _ = tracker.StepSuccess(StepDownloadLink, downloadURLLine(liveURL)) + url = liveURL + size = liveSize + } else { + standardURL, err := runStandardBackupFlow(ctx, tracker, opts, deps, out, interval, timeout) + if err != nil { + return "", err + } + url = standardURL + + st, err := deps.FetchStatus(ctx) + if err != nil { + return "", err + } + job := exportJobFor(st) + if job == nil { + return "", errors.New("Export job not found") + } + if job.BytesWritten == "" { + return "", errors.New("Export job metadata does not contain bytesWritten") + } + _, _ = fmt.Sscanf(job.BytesWritten, "%d", &size) + } + + if opts.SkipDownload { + // export-sql.ts:420-427. + _ = tracker.StepSkipped(StepConfirmEnoughStorage) + _ = tracker.StepSkipped(StepDownload) + return "", nil + } + + // Prompt errors (e.g. non-interactive) decline like Node's enquirer + // reject path; FreeBytes errors propagate as-is. + cont, _, err := ConfirmEnoughStorage(size, deps.FreeBytes, deps.Confirm) + if err != nil && !cont { + cont = false + } + if !cont { + _ = tracker.StepFailed(StepConfirmEnoughStorage) + return "", errors.New("Command canceled by user.") + } + _ = tracker.StepSuccess(StepConfirmEnoughStorage) + + // export-sql.ts:449-474 — download with the progress line. + if err := deps.Download(ctx, url, filename, func(current, total int64) { + if total > 0 { + tracker.SetProgress(fmt.Sprintf("- %.2f%% (%s/%s)", + 100*float64(current)/float64(total), FormatBytes(current), FormatBytes(total))) + } + }); err != nil { + _ = tracker.StepFailed(StepDownload) + return "", fmt.Errorf("Error downloading exported file: %s", err.Error()) + } + _ = tracker.StepSuccess(StepDownload) + return filename, nil +} + +// runStandardBackupFlow ports runBackup (export-sql.ts:481). +func runStandardBackupFlow(ctx context.Context, tracker *tui.ProgressTracker, opts Options, deps Deps, out io.Writer, interval, timeout time.Duration) (string, error) { + if opts.GenerateBackup { + // export-sql.ts:350-355 NOTICE block. + notice := "\n" + color.YellowString("NOTICE: ") + + "If a recent database backup does not exist, a new one will be generated for this environment. " + + "Learn more about this: https://docs.wpvip.com/databases/backups/download-a-full-database-backup/ \n" + fmt.Fprintln(out, notice) + if err := deps.RunBackup(ctx); err != nil { + return "", err + } + } + + st, err := deps.FetchStatus(ctx) + if err != nil { + return "", err + } + if st.LatestBackup == nil { + return "", fmt.Errorf("No backup found for site %s", opts.AppName) + } + latest := st.LatestBackup + + var prepareInfo []string + tool := latest.SQLDumpTool + if tool == "" { + tool = st.EnvSQLDumpTool + } + if tool == "mydumper" { + prepareInfo = append(prepareInfo, color.New(color.FgYellow, color.Bold).Sprint("WARNING:")+ + " This is a large or complex database. The backup file for this database is generated with MyDumper. The file can only be loaded with MyLoader. For more information: https://github.com/mydumper/mydumper") + } + + if exportJobFor(st) != nil { + prepareInfo = append(prepareInfo, + fmt.Sprintf("Attaching to an existing export for the backup with timestamp %s", latest.CreatedAt)) + } else { + prepareInfo = append(prepareInfo, + fmt.Sprintf("Exporting database backup with timestamp %s", latest.CreatedAt)) + if err := deps.CreateExport(ctx, latest.ID); err != nil { + // export-sql.ts:525-543. + if strings.Contains(err.Error(), "Backup Copy already in progress") { + return "", fmt.Errorf("There is an export job already running for this environment: https://dashboard.wpvip.com/apps/%d/%s/database/backups\nCurrently, we allow only one export job at a time, per site. Please try again later.", + opts.AppID, opts.EnvUniqueLabel) + } + return "", fmt.Errorf("Error creating export job: %s", err.Error()) + } + } + + // poll preflight success → PREPARE done (export-sql.ts:547-553). + if err := pollStep(ctx, deps, interval, timeout, "preflight"); err != nil { + return "", err + } + _ = tracker.StepSuccess(StepPrepare, prepareInfo...) + + // poll upload_backup success → CREATE done (export-sql.ts:555-560). + if err := pollStep(ctx, deps, interval, timeout, "upload_backup"); err != nil { + return "", err + } + _ = tracker.StepSuccess(StepCreate) + + url, err := deps.GenerateLink(ctx, latest.ID) + if err != nil { + return "", err + } + _ = tracker.StepSuccess(StepDownloadLink, downloadURLLine(url)) + return url, nil +} + +// pollStep waits until the export job's step with the given id reports +// success (isPrepared/isCreated, export-sql.ts:323-337). Node calls pollUntil +// with no timeout, so this sits under the shared 6h ceiling; on expiry the +// PollingTimeoutError propagates uncaught out of runBackup, surfacing as +// "Polling timed out". +func pollStep(ctx context.Context, deps Deps, interval, timeout time.Duration, stepID string) error { + _, err := poll.Until(ctx, deps.FetchStatus, interval, + func(st *BackupAndJobs) bool { + job := exportJobFor(st) + return job != nil && job.StepStatus[stepID] == "success" + }, timeout) + return err +} + +// downloadURLLine — generateDownloadURLOutputString (export-sql.ts:477). +func downloadURLLine(url string) string { + return color.GreenString("Download URL") + ": " + url +} + +// checkWritable mirrors fs.accessSync(dir, W_OK) (export-sql.ts:378). +func checkWritable(dir string) error { + fi, err := os.Stat(dir) + if err != nil { + return err + } + if !fi.IsDir() { + return fmt.Errorf("not a directory: %s", dir) + } + probe, err := os.CreateTemp(dir, ".vip-write-probe-*") + if err != nil { + return err + } + name := probe.Name() + probe.Close() + return os.Remove(name) +} diff --git a/internal/sqlexport/export_test.go b/internal/sqlexport/export_test.go new file mode 100644 index 000000000..d3a5e3ec1 --- /dev/null +++ b/internal/sqlexport/export_test.go @@ -0,0 +1,313 @@ +package sqlexport + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/tui" +) + +func exportTracker() *tui.ProgressTracker { return tui.NewProgressTracker(Steps()) } + +// happyDeps builds Deps for a standard (non-live) flow that completes. +func happyDeps(t *testing.T, downloadBody string) (Deps, *[]string) { + t.Helper() + var calls []string + fetchCount := 0 + deps := Deps{ + FetchStatus: func(ctx context.Context) (*BackupAndJobs, error) { + fetchCount++ + job := ExportJob{ + BackupID: 11, + BytesWritten: "2048", + StepStatus: map[string]string{}, + } + // First fetch: no job yet (so CreateExport fires); later + // fetches: steps progress to success. + switch { + case fetchCount == 1: + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + }, nil + case fetchCount <= 3: + job.StepStatus["preflight"] = "success" + default: + job.StepStatus["preflight"] = "success" + job.StepStatus["upload_backup"] = "success" + } + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{job}, + }, nil + }, + CreateExport: func(ctx context.Context, backupID int64) error { + calls = append(calls, "create") + return nil + }, + GenerateLink: func(ctx context.Context, backupID int64) (string, error) { + calls = append(calls, "link") + return "https://dl.example/backup.sql.gz", nil + }, + RunBackup: func(ctx context.Context) error { calls = append(calls, "backup"); return nil }, + Confirm: func(string) (bool, error) { return true, nil }, + FreeBytes: func() (int64, error) { return 1 << 40, nil }, + Download: func(ctx context.Context, url, dest string, onProgress OnProgress) error { + calls = append(calls, "download:"+url+"->"+dest) + if onProgress != nil { + onProgress(1024, 2048) + onProgress(2048, 2048) + } + return nil + }, + } + _ = downloadBody + return deps, &calls +} + +func TestExportRunHappyPath(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "data") + var out bytes.Buffer + tr := exportTracker() + saved, err := Run(context.Background(), tr, Options{ + AppID: 42, AppName: "parityapp", EnvUniqueLabel: "develop", Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(*calls, "|") + if !strings.Contains(joined, "create") || !strings.Contains(joined, "link") || + !strings.Contains(joined, "download:https://dl.example/backup.sql.gz->exported.sql.gz") { + t.Errorf("calls = %v", *calls) + } + // Run returns the saved path; the caller (not Run) prints "File saved to", + // after stopping its progress renderer. + if saved != "exported.sql.gz" { + t.Errorf("saved = %q, want exported.sql.gz", saved) + } + if strings.Contains(out.String(), "File saved to") { + t.Errorf("Run must not print 'File saved to'; out = %q", out.String()) + } + if !strings.Contains(tr.Frame(), "Exporting database backup with timestamp 2026-06-11 10:00:00") { + t.Errorf("frame missing prepare info: %q", tr.Frame()) + } +} + +func TestExportRunNoBackup(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + return &BackupAndJobs{}, nil + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "No backup found for site parityapp" { + t.Errorf("err = %v", err) + } +} + +func TestExportRunAlreadyInProgress(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.CreateExport = func(ctx context.Context, backupID int64) error { + return errors.New("GraphQL: Backup Copy already in progress") + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", EnvUniqueLabel: "develop", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + want := "There is an export job already running for this environment: https://dashboard.wpvip.com/apps/42/develop/database/backups" + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v", err) + } +} + +func TestExportRunSkipDownload(t *testing.T) { + deps, calls := happyDeps(t, "") + var out bytes.Buffer + saved, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", SkipDownload: true, Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(*calls, "|"), "download:") { + t.Error("skip-download must not download") + } + if saved != "" { + t.Errorf("skip-download must save nothing; saved = %q", saved) + } + if strings.Contains(out.String(), "File saved to") { + t.Errorf("out = %q", out.String()) + } +} + +func TestExportRunStorageDeclineCancels(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.FreeBytes = func() (int64, error) { return 1, nil } // force prompt + deps.Confirm = func(string) (bool, error) { return false, nil } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "Command canceled by user." { + t.Errorf("err = %v", err) + } +} + +func TestExportRunMissingBytesWritten(t *testing.T) { + deps, _ := happyDeps(t, "") + orig := deps.FetchStatus + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + st, err := orig(ctx) + if err != nil { + return nil, err + } + for i := range st.Jobs { + st.Jobs[i].BytesWritten = "" + } + return st, nil + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "Export job metadata does not contain bytesWritten" { + t.Errorf("err = %v", err) + } +} + +func TestExportRunGenerateBackupPrintsNotice(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "") + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", GenerateBackup: true, Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(*calls, "|"), "backup") { + t.Error("RunBackup must fire with --generate-backup") + } + if !strings.Contains(out.String(), "NOTICE: ") || + !strings.Contains(out.String(), "If a recent database backup does not exist") { + t.Errorf("out = %q", out.String()) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling pins the ceiling `vip export +// sql` inherits from Node: export-sql.ts:547 and :555 both call pollUntil +// with no explicit timeout, so both get the 6h default (utils.ts:18). +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } +} + +// TestExportRunStopsWhenJobNeverPrepares is the regression test for the +// unbounded pollStep loop: an export job whose preflight step never reaches +// "success" used to spin forever with nothing cancelling the context. +func TestExportRunStopsWhenJobNeverPrepares(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, _ := happyDeps(t, "") + fetches := 0 + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + fetches++ + // The job exists (so no CreateExport) but preflight never succeeds. + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{{ + BackupID: 11, + StepStatus: map[string]string{"preflight": "running"}, + }}, + }, nil + } + + done := make(chan error, 1) + go func() { + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }, deps, &out) + done <- err + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Polling timed out" { + t.Errorf("err = %v, want %q", err, "Polling timed out") + } + if fetches < 2 { + t.Errorf("fetches = %d, want the loop to have actually polled", fetches) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the export-job poll loop is unbounded") + } +} + +// TestExportRunStopsWhenJobNeverUploads covers the SECOND pollUntil +// (export-sql.ts:555): preflight succeeds, upload_backup never does. +func TestExportRunStopsWhenJobNeverUploads(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, _ := happyDeps(t, "") + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{{ + BackupID: 11, + StepStatus: map[string]string{"preflight": "success", "upload_backup": "running"}, + }}, + }, nil + } + + done := make(chan error, 1) + go func() { + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }, deps, &out) + done <- err + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Polling timed out" { + t.Errorf("err = %v, want %q", err, "Polling timed out") + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the upload_backup poll loop is unbounded") + } +} + +func TestExportRunLiveCopyPath(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "") + deps.StartLive = func(ctx context.Context, cfg []byte) (string, error) { + if !strings.Contains(string(cfg), `"type":"tables"`) || !strings.Contains(string(cfg), "wp_comments") { + t.Errorf("cfg = %s", cfg) + } + return "copy-1", nil + } + deps.PollLiveURL = func(ctx context.Context, copyID string) (string, int64, error) { + if copyID != "copy-1" { + t.Errorf("copyID = %q", copyID) + } + return "https://dl.example/partial.sql.gz", 4096, nil + } + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + LiveCopy: &LiveCopyCLIOptions{UseLiveBackupCopy: true, Tables: []string{"wp_posts", "wp_comments"}}, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(*calls, "|"), "download:https://dl.example/partial.sql.gz") { + t.Errorf("calls = %v", *calls) + } +} diff --git a/internal/sqlexport/format.go b/internal/sqlexport/format.go new file mode 100644 index 000000000..b73a56f8e --- /dev/null +++ b/internal/sqlexport/format.go @@ -0,0 +1,44 @@ +// Package sqlexport ports src/commands/export-sql.ts — the `vip export +// sql` workflow: latest-backup lookup, export-job creation + polling, +// download-link generation, partial exports (live backup copy), the +// disk-space confirmation, and the streamed download. +package sqlexport + +import ( + "fmt" + "math" +) + +// formatBytesBase ports format.ts formatBytes: powers of `base` +// (1024 for formatBytes, 1000 for formatMetricBytes), 2 decimals, +// sizes [bytes KB MB GB TB], "0 bytes" for zero. +func formatBytesBase(bytes int64, base float64) string { + if bytes == 0 { + return "0 bytes" + } + sizes := []string{"bytes", "KB", "MB", "GB", "TB"} + i := int(math.Floor(math.Log(float64(bytes)) / math.Log(base))) + if i >= len(sizes) { + i = len(sizes) - 1 + } + if i < 0 { + i = 0 + } + value := float64(bytes) / math.Pow(base, float64(i)) + // Node: parseFloat(value.toFixed(decimals)) — trailing zeros dropped. + s := fmt.Sprintf("%.2f", value) + // Trim trailing zeros and a dangling dot, mirroring parseFloat. + for len(s) > 0 && s[len(s)-1] == '0' { + s = s[:len(s)-1] + } + if len(s) > 0 && s[len(s)-1] == '.' { + s = s[:len(s)-1] + } + return s + " " + sizes[i] +} + +// FormatBytes — format.ts formatBytes default (1024-based). +func FormatBytes(bytes int64) string { return formatBytesBase(bytes, 1024) } + +// FormatMetricBytes — format.ts:231 (1000-based, "how it's displayed on Macs"). +func FormatMetricBytes(bytes int64) string { return formatBytesBase(bytes, 1000) } diff --git a/internal/sqlexport/livecopy.go b/internal/sqlexport/livecopy.go new file mode 100644 index 000000000..3bcb91fac --- /dev/null +++ b/internal/sqlexport/livecopy.go @@ -0,0 +1,150 @@ +package sqlexport + +import ( + "errors" + "fmt" + "os" + "strings" + + "encoding/json/jsontext" + json "encoding/json/v2" +) + +// LiveCopyCLIOptions ports LiveBackupCopyCLIOptions (live-backup-copy.ts:13). +type LiveCopyCLIOptions struct { + UseLiveBackupCopy bool + SiteIDs []string + Tables []string + WpcliCommand string + ConfigFile string +} + +// ParseLiveCopyCLIOptions ports parseLiveBackupCopyCLIOptions +// (live-backup-copy.ts:21): exclusivity rules + comma-split with trim. +func ParseLiveCopyCLIOptions(configFile string, tables, siteIDs []string, wpcliCommand string) (*LiveCopyCLIOptions, error) { + if configFile != "" && (len(tables) > 0 || len(siteIDs) > 0 || wpcliCommand != "") { + return nil, errors.New("The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options. Please use only one of these options at a time.") + } + if wpcliCommand != "" && (len(tables) > 0 || len(siteIDs) > 0) { + return nil, errors.New("The --wpcli-command option cannot be used with the --table or --site-id options. Please use only one of these options at a time.") + } + + opts := &LiveCopyCLIOptions{} + split := func(values []string) []string { + var out []string + for _, v := range values { + for _, part := range strings.Split(v, ",") { + out = append(out, strings.TrimSpace(part)) + } + } + return out + } + if len(tables) > 0 { + opts.Tables = split(tables) + opts.UseLiveBackupCopy = true + } + if len(siteIDs) > 0 { + opts.SiteIDs = split(siteIDs) + opts.UseLiveBackupCopy = true + } + if configFile != "" { + opts.ConfigFile = configFile + opts.UseLiveBackupCopy = true + } + if wpcliCommand != "" { + opts.WpcliCommand = wpcliCommand + opts.UseLiveBackupCopy = true + } + return opts, nil +} + +// LiveCopyConfig ports DBLiveCopyConfig (live-backup-copy.ts:120) for the +// FLAG path only — it is the Go spelling of the object literal Node builds in +// getLiveBackupConfigFromCLIOptions (export-sql.ts:639-644): +// +// return { +// type, +// tables, // undefined unless --table was passed +// site_ids: siteIds, +// wpcli_command: this.liveBackupCopyCLIOptions?.wpcliCommand, +// }; +// +// The `omitempty` tags reproduce JSON.stringify dropping `undefined` fields. +// +// It is deliberately NOT used to parse --config-file. Node's +// loadLiveBackupCopyConfig is `JSON.parse( … ) as DBLiveCopyConfig`: a +// compile-time cast, not a runtime schema. Decoding a user's config file into +// this struct silently discarded every key it doesn't declare and every empty +// collection, changing the scope of the export without any signal. See +// BuildConfig. +type LiveCopyConfig struct { + Tool string `json:"tool,omitempty"` + Type string `json:"type"` + // Values are `string | boolean` in Node (live-backup-copy.ts:123), hence + // `any` rather than `string`. + Tables map[string]map[string]any `json:"tables,omitempty"` + SiteIDs []int64 `json:"site_ids,omitempty"` + WpcliCommand string `json:"wpcli_command,omitempty"` +} + +// BuildConfig ports getLiveBackupConfigFromCLIOptions (export-sql.ts:616) + +// loadLiveBackupCopyConfig (export-sql.ts:647). It returns the JSON document +// that becomes LiveBackupCopyConfigInput.config (a `JSON` scalar in the +// schema), so the two paths differ: +// +// - --config-file: the file's bytes are validated as JSON and passed +// through VERBATIM, because that is what Node does. `JSON.parse` + +// an `as` cast keeps every key the user wrote — including ones the CLI +// has never heard of (`exclude_tables`, `limit`, per-table `where`) — +// and startLiveBackupCopy hands the whole object to the server. Anything +// the CLI drops here silently changes which rows the user gets back, +// with exit 0. Parsing uses encoding/json/v2: a config file is +// untrusted user input, which is exactly where v1 is finicky. +// +// - flags: the LiveCopyConfig literal above, marshaled. +func BuildConfig(opts *LiveCopyCLIOptions) ([]byte, error) { + if opts.ConfigFile != "" { + if _, err := os.Stat(opts.ConfigFile); err != nil { + return nil, fmt.Errorf("Configuration file not found: %s", opts.ConfigFile) + } + raw, err := os.ReadFile(opts.ConfigFile) // #nosec G304 -- user-supplied CLI path + if err != nil { + return nil, fmt.Errorf("Error reading configuration file: %s - %s", opts.ConfigFile, err.Error()) + } + // Validate only — the decoded shape is not inspected. `any` accepts + // any JSON document, matching JSON.parse: Node throws only on a + // SyntaxError, never on an unexpected shape. AllowDuplicateNames + // keeps us from being STRICTER than JSON.parse, which takes the last + // of a duplicated member instead of failing. + var probe any + if err := json.Unmarshal(raw, &probe, jsontext.AllowDuplicateNames(true)); err != nil { + return nil, fmt.Errorf("Invalid JSON in configuration file: %s - %s", opts.ConfigFile, err.Error()) + } + // Re-marshal the *validated* value rather than shipping the file's + // raw bytes: that normalises whitespace and rejects anything the + // validator accepted but an embedder would mangle, while preserving + // every key, every empty collection and every value type. + return json.Marshal(probe) + } + + cfg := &LiveCopyConfig{Type: "tables"} // BackupLiveCopyType.TABLES default (export-sql.ts:621) + if len(opts.Tables) > 0 { + cfg.Tables = map[string]map[string]any{} + for _, t := range opts.Tables { + cfg.Tables[t] = map[string]any{} + } + } + if len(opts.SiteIDs) > 0 { + cfg.Type = "site_ids" + for _, id := range opts.SiteIDs { + var n int64 + _, _ = fmt.Sscanf(strings.TrimSpace(id), "%d", &n) + cfg.SiteIDs = append(cfg.SiteIDs, n) + } + } + if opts.WpcliCommand != "" { + cfg.Type = "wpcli_command" + cfg.WpcliCommand = opts.WpcliCommand + } + return json.Marshal(cfg) +} diff --git a/internal/sqlexport/livecopy_test.go b/internal/sqlexport/livecopy_test.go new file mode 100644 index 000000000..5c1717b73 --- /dev/null +++ b/internal/sqlexport/livecopy_test.go @@ -0,0 +1,273 @@ +package sqlexport + +import ( + "os" + "path/filepath" + "strings" + "testing" + + json "encoding/json/v2" +) + +func TestParseLiveCopyCLIOptionsExclusivity(t *testing.T) { + _, err := ParseLiveCopyCLIOptions("cfg.json", []string{"wp_posts"}, nil, "") + if err == nil || !strings.Contains(err.Error(), "The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options.") { + t.Errorf("err = %v", err) + } + _, err = ParseLiveCopyCLIOptions("", []string{"wp_posts"}, nil, "wp post list") + if err == nil || !strings.Contains(err.Error(), "The --wpcli-command option cannot be used with the --table or --site-id options.") { + t.Errorf("err = %v", err) + } +} + +func TestParseLiveCopyCLIOptionsCommaSplit(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", []string{"wp_posts, wp_comments", "wp_users"}, []string{"2,3"}, "") + if err != nil { + t.Fatal(err) + } + if !opts.UseLiveBackupCopy { + t.Error("UseLiveBackupCopy must be set") + } + if len(opts.Tables) != 3 || opts.Tables[1] != "wp_comments" { + t.Errorf("tables = %v", opts.Tables) + } + if len(opts.SiteIDs) != 2 || opts.SiteIDs[1] != "3" { + t.Errorf("siteIDs = %v", opts.SiteIDs) + } +} + +func TestParseLiveCopyCLIOptionsEmpty(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", nil, nil, "") + if err != nil { + t.Fatal(err) + } + if opts.UseLiveBackupCopy { + t.Error("no options must not enable live copy") + } +} + +// decodePayload reads the JSON document BuildConfig produces — i.e. exactly +// what lands in LiveBackupCopyConfigInput.config on the wire. +func decodePayload(t *testing.T, raw []byte) map[string]any { + t.Helper() + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("payload is not a JSON object: %v (%s)", err, raw) + } + return got +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "cfg.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestBuildConfigFromFlags(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, SiteIDs: []string{"2", "3"}}) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + if got["type"] != "site_ids" { + t.Errorf("type = %v, want site_ids", got["type"]) + } + ids, _ := got["site_ids"].([]any) + if len(ids) != 2 || ids[1] != float64(3) { + t.Errorf("site_ids = %v", got["site_ids"]) + } + // Node's getLiveBackupConfigFromCLIOptions leaves the unused fields + // `undefined`, and JSON.stringify drops them — so the flag path must NOT + // emit empty tables/wpcli_command keys. + if _, ok := got["tables"]; ok { + t.Errorf("flag path emitted a tables key: %v", got) + } + if _, ok := got["wpcli_command"]; ok { + t.Errorf("flag path emitted a wpcli_command key: %v", got) + } + + raw, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, WpcliCommand: "wp post list"}) + if err != nil { + t.Fatal(err) + } + got = decodePayload(t, raw) + if got["type"] != "wpcli_command" || got["wpcli_command"] != "wp post list" { + t.Errorf("cfg = %v", got) + } +} + +func TestBuildConfigFromFile(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"tables","tables":{"wp_posts":{}}}`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + tables, _ := got["tables"].(map[string]any) + if got["type"] != "tables" || len(tables) != 1 { + t.Errorf("cfg = %v", got) + } + + dir := t.TempDir() + _, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, ConfigFile: filepath.Join(dir, "nope.json")}) + if err == nil || !strings.Contains(err.Error(), "Configuration file not found:") { + t.Errorf("err = %v", err) + } + + _, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, ConfigFile: writeConfig(t, "{nope")}) + if err == nil || !strings.Contains(err.Error(), "Invalid JSON in configuration file:") { + t.Errorf("err = %v", err) + } +} + +// TestBuildConfigFromFilePreservesUnknownKeys is register 2.18's first +// defect. Node's loadLiveBackupCopyConfig (export-sql.ts:647-662) is a bare +// `JSON.parse( … ) as DBLiveCopyConfig` — a compile-time cast with no runtime +// filtering — and startLiveBackupCopy passes the parsed object straight into +// the GraphQL `config: JSON` scalar. Every key the user wrote reaches the +// server. Go decoded into a typed struct, so keys the struct didn't declare +// (`exclude_tables`, `limit`, per-table `where` clauses) were silently +// dropped: the user got a dump with the WRONG SCOPE and exit 0. +func TestBuildConfigFromFilePreservesUnknownKeys(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{ + "type": "tables", + "tool": "mysqldump", + "tables": {"wp_posts": {"where": "ID > 100"}}, + "exclude_tables": ["wp_options", "wp_usermeta"], + "limit": 500 + }`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + + excluded, ok := got["exclude_tables"].([]any) + if !ok || len(excluded) != 2 || excluded[0] != "wp_options" { + t.Errorf("exclude_tables was dropped: %v", got) + } + if got["limit"] != float64(500) { + t.Errorf("limit was dropped: %v", got) + } + tables, _ := got["tables"].(map[string]any) + wpPosts, _ := tables["wp_posts"].(map[string]any) + if wpPosts["where"] != "ID > 100" { + t.Errorf("per-table option was dropped: %v", got) + } + if got["tool"] != "mysqldump" { + t.Errorf("tool was dropped: %v", got) + } +} + +// TestBuildConfigFromFilePreservesEmptyCollections is register 2.18's second +// defect: `omitempty` on the typed struct deleted collections the user wrote +// explicitly. `{"site_ids": []}` is a meaningful (if degenerate) scope; Node +// sends it, Go used to send a config with no site_ids at all — which the +// server reads as a different scope entirely. +func TestBuildConfigFromFilePreservesEmptyCollections(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"site_ids","site_ids":[],"tables":{},"wpcli_command":""}`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + if _, ok := got["site_ids"]; !ok { + t.Errorf("empty site_ids was dropped by omitempty: %v", got) + } + if _, ok := got["tables"]; !ok { + t.Errorf("empty tables was dropped by omitempty: %v", got) + } + if _, ok := got["wpcli_command"]; !ok { + t.Errorf("empty wpcli_command was dropped by omitempty: %v", got) + } +} + +// TestBuildConfigFromFileAcceptsBooleanTableOptions is register 2.18's third +// defect. Node's own type says a per-table option value may be a boolean: +// +// tables?: Record< string, Record< string, string | boolean > > +// +// (live-backup-copy.ts:123). Go's `map[string]map[string]string` made that a +// hard unmarshal failure, so a config file Node accepts aborted the export. +func TestBuildConfigFromFileAcceptsBooleanTableOptions(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, + `{"type":"tables","tables":{"wp_posts":{"where":"ID > 1","structure_only":true,"skip_data":false}}}`), + }) + if err != nil { + t.Fatalf("boolean per-table option rejected (Node allows string | boolean): %v", err) + } + got := decodePayload(t, raw) + tables, _ := got["tables"].(map[string]any) + wpPosts, _ := tables["wp_posts"].(map[string]any) + if wpPosts["structure_only"] != true { + t.Errorf("structure_only = %v, want true", wpPosts["structure_only"]) + } + if wpPosts["skip_data"] != false { + t.Errorf("skip_data = %v, want false", wpPosts["skip_data"]) + } + if wpPosts["where"] != "ID > 1" { + t.Errorf("where = %v, want the string", wpPosts["where"]) + } +} + +// TestBuildConfigFromFileAllowsDuplicateKeys keeps the jsonv2 port from being +// STRICTER than Node. `JSON.parse` accepts a duplicated object member and +// keeps the last one; jsonv2 rejects duplicates by default, which would turn +// a config file Node runs fine into a hard failure. That would be a new +// divergence introduced by the fix, so it is opted out of explicitly. +func TestBuildConfigFromFileAllowsDuplicateKeys(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"tables","type":"site_ids","site_ids":[4]}`), + }) + if err != nil { + t.Fatalf("duplicate key rejected; JSON.parse accepts it (last wins): %v", err) + } + got := decodePayload(t, raw) + if got["type"] != "site_ids" { + t.Errorf("type = %v, want site_ids (JSON.parse keeps the LAST duplicate)", got["type"]) + } +} + +// TestSiteIDCommaSplitIsADeliberateKeep pins cutover register item 1.12. +// +// Node's bin declares `--site-id` with `Number.parseInt` as the coercer +// (src/bin/vip-export-sql.js:86-91), so `--site-id=2,3` arrives as the number +// 2 and site 3 is silently dropped — even though Node's own `--site-id=2,3` +// usage example (vip-export-sql.js:44-46) promises both sites. Go splits on +// the comma and exports BOTH, matching the documented behaviour rather than +// the shipped behaviour. +// +// That divergence is a decided KEEP. This test exists so a future agent +// "fixing" it toward Node has to delete an explicit assertion rather than +// quietly regress the scope of a partial export. +func TestSiteIDCommaSplitIsADeliberateKeep(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", nil, []string{"2,3"}, "") + if err != nil { + t.Fatal(err) + } + if len(opts.SiteIDs) != 2 || opts.SiteIDs[0] != "2" || opts.SiteIDs[1] != "3" { + t.Fatalf("SiteIDs = %v, want [2 3] (register 1.12 KEEP; Node's parseInt yields just [2])", opts.SiteIDs) + } + + raw, err := BuildConfig(opts) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + ids, _ := got["site_ids"].([]any) + if len(ids) != 2 || ids[0] != float64(2) || ids[1] != float64(3) { + t.Errorf("site_ids = %v, want [2 3] on the wire (register 1.12 KEEP)", got["site_ids"]) + } +} diff --git a/internal/sqlvalidation/devenv_checks_test.go b/internal/sqlvalidation/devenv_checks_test.go new file mode 100644 index 000000000..7e55a90d5 --- /dev/null +++ b/internal/sqlvalidation/devenv_checks_test.go @@ -0,0 +1,134 @@ +package sqlvalidation + +import ( + "strings" + "testing" +) + +// devEnvOptions mirrors the option set Node's dev-env import passes +// (src/commands/dev-env-import-sql.ts:96-100): skipChecks is EMPTY (for a +// mysqldump), which overrides DEFAULT_VALIDATION_OPTIONS.skipChecks and turns +// the two DEV_ENV_SPECIFIC_CHECKS back on, plus the expected local domain as +// siteHomeUrlLando's extraCheckParam. +func devEnvOptions(domain string) Options { + return Options{ExtraCheckParams: map[string]string{CheckSiteHomeURLLando: domain}} +} + +func TestDevEnvOptionsRegisterUseStatement(t *testing.T) { + res, err := ValidateWith(strings.NewReader("USE my_database;\n"), devEnvOptions("e.vipdev.site"), nil) + if err != nil { + t.Fatalf("ValidateWith: %v", err) + } + c := findCheck(t, res, CheckUseStatement) + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("useStatement: got %#v, want one result on line 1", c.Results) + } +} + +func TestDevEnvUseStatementIsCaseInsensitiveAndAnchored(t *testing.T) { + res, _ := ValidateWith(strings.NewReader("use other_db;\nSELECT 'USE something';\n"), devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckUseStatement) + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("useStatement: got %#v, want only the anchored line 1 match", c.Results) + } +} + +// Node sql.ts:344-369. A siteurl/home pointing anywhere but the local domain +// is the finding that matters most for dev-env: importing production SQL +// without a search-replace leaves the LOCAL site redirecting to production. +func TestDevEnvSiteHomeURLLandoFlagsForeignDomain(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'siteurl','https://example.com');` + "\n") + res, _ := ValidateWith(in, devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckSiteHomeURLLando) + if len(c.Results) != 1 { + t.Fatalf("siteHomeUrlLando: got %#v, want 1 result", c.Results) + } + got := c.Results[0] + if got.FalsePositive { + t.Errorf("foreign domain marked falsePositive: %#v", got) + } + if got.Line != 1 { + t.Errorf("line = %d, want 1", got.Line) + } + want := `Use '--search-replace="example.com,e.vipdev.site"' switch to replace the domain` + if got.Recommendation != want { + t.Errorf("recommendation =\n %q\nwant %q", got.Recommendation, want) + } +} + +// Node's matchHandler returns { falsePositive: true } for three shapes; each +// must NOT produce a finding. +func TestDevEnvSiteHomeURLLandoFalsePositives(t *testing.T) { + cases := []struct { + name string + sql string + }{ + // Not an absolute http(s) URL — Node's /^https?:\/\//i test fails. + {"relative value", `INSERT INTO wp_options VALUES (1,'siteurl','/blog');`}, + // Scheme only: empty after stripping -> trim() is falsy. + {"scheme only", `INSERT INTO wp_options VALUES (1,'home','https://');`}, + // Already points at the local environment. + {"matches expected domain", `INSERT INTO wp_options VALUES (1,'home','https://e.vipdev.site');`}, + // Subdomain of the local environment still "includes" it. + {"subdomain of expected", `INSERT INTO wp_options VALUES (1,'home','https://sub.e.vipdev.site/x');`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, _ := ValidateWith(strings.NewReader(tc.sql+"\n"), devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckSiteHomeURLLando) + for _, r := range c.Results { + if !r.FalsePositive { + t.Errorf("expected falsePositive, got %#v", r) + } + } + }) + } +} + +// Node's matcher for this check has no /i flag, so it is case-SENSITIVE on the +// option name (unlike most other checks). Pinning it stops a future "cleanup" +// from adding (?i) and diverging. +func TestDevEnvSiteHomeURLLandoMatcherIsCaseSensitive(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'SITEURL','https://example.com');` + "\n") + res, _ := ValidateWith(in, devEnvOptions("e.vipdev.site"), nil) + if c := findCheck(t, res, CheckSiteHomeURLLando); len(c.Results) != 0 { + t.Errorf("uppercase option name matched: %#v", c.Results) + } +} + +// SkipChecks is honoured: Node passes ['dropTable','dropDB'] for a MyDumper +// dump (dev-env-import-sql.ts:98). +func TestValidateWithSkipChecksOmitsChecks(t *testing.T) { + opts := devEnvOptions("e.vipdev.site") + opts.SkipChecks = []string{"dropTable", "dropDB"} + res, _ := ValidateWith(strings.NewReader("DROP DATABASE wordpress;\n"), opts, nil) + for _, c := range res.Checks { + if c.Key == "dropTable" || c.Key == "dropDB" { + t.Errorf("%s must not be registered when skipped", c.Key) + } + } +} + +// REGRESSION GUARD for the platform path: `vip import validate-sql` and +// `vip import sql` run with DEFAULT_VALIDATION_OPTIONS, whose skipChecks is +// DEV_ENV_SPECIFIC_CHECKS. Neither dev-env check may ever appear there. +func TestPlatformValidateSkipsBothDevEnvChecks(t *testing.T) { + sql := "USE my_database;\n" + + `INSERT INTO wp_options VALUES (1,'siteurl','https://example.com');` + "\n" + res, err := Validate(strings.NewReader(sql)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + for _, c := range res.Checks { + if c.Key == CheckUseStatement || c.Key == CheckSiteHomeURLLando { + t.Errorf("%s must stay skipped on the platform validate-sql path", c.Key) + } + } +} + +func TestPlatformOptionsSkipsDevEnvSpecificChecks(t *testing.T) { + got := PlatformOptions().SkipChecks + if len(got) != 2 || got[0] != CheckUseStatement || got[1] != CheckSiteHomeURLLando { + t.Errorf("PlatformOptions().SkipChecks = %v, want %v", got, DevEnvSpecificChecks) + } +} diff --git a/internal/sqlvalidation/filename.go b/internal/sqlvalidation/filename.go new file mode 100644 index 000000000..2944dba91 --- /dev/null +++ b/internal/sqlvalidation/filename.go @@ -0,0 +1,30 @@ +package sqlvalidation + +import ( + "errors" + "path/filepath" + "regexp" + "strings" +) + +// validFilenameRE — Node sql.ts:106's /^[a-z0-9\-_.]+$/i. +var validFilenameRE = regexp.MustCompile(`(?i)^[a-z0-9\-_.]+$`) + +// ValidateFilename ports validateFilename (sql.ts:105): the import file's +// basename may only contain [0-9 a-z A-Z - _ .]. +func ValidateFilename(filename string) error { + if !validFilenameRE.MatchString(filename) { + return errors.New("Error: The characters used in the name of a file for import are limited to [0-9,a-z,A-Z,-,_,.]") + } + return nil +} + +// ValidateImportFileExtension ports validateImportFileExtension +// (sql.ts:98): only .sql and .gz files can be imported. +func ValidateImportFileExtension(fileName string) error { + ext := strings.ToLower(filepath.Ext(fileName)) + if ext != ".sql" && ext != ".gz" { + return errors.New("Invalid file extension. Please provide a .sql or .gz file.") + } + return nil +} diff --git a/internal/sqlvalidation/line_by_line.go b/internal/sqlvalidation/line_by_line.go new file mode 100644 index 000000000..680cbf49c --- /dev/null +++ b/internal/sqlvalidation/line_by_line.go @@ -0,0 +1,91 @@ +// Package sqlvalidation ports Node's src/lib/validations/sql.ts + +// is-multi-site-sql-dump.ts + line-by-line.ts to Go. Local-only — no +// network calls. +package sqlvalidation + +import ( + "bufio" + "io" +) + +// readBufSize is the size of the rolling read buffer. It is NOT a per-line +// ceiling: lines longer than this are stitched together from successive +// ReadSlice fragments (see ScanLines). +// +// Node's line-by-line.ts uses fd.readLines(), which imposes no per-line +// limit whatsoever. We previously used a bufio.Scanner with a 16MB cap, +// which rejected dumps Node validates fine — `mysqldump --extended-insert` +// packs a whole table into one INSERT, mydumper does the same, and a single +// row with a multi-MB LONGTEXT column is enough on its own. Worse, the +// failure surfaced to the user as the raw Go internal +// "bufio.Scanner: token too long". +// +// Memory: only ONE line is ever held at a time, so a multi-GB dump costs +// max(readBufSize, longest line) — the file is never loaded whole. +const readBufSize = 256 * 1024 + +// ScanLines reads r line-by-line and calls fn for each line payload. Line +// numbers are 1-indexed (Node's lineNum starts at 1 in sql.ts). Returns the +// first non-nil error returned by fn (stops scanning on that line) or any +// underlying read error. +// +// Line splitting matches bufio.ScanLines and Node's readline: '\n' +// terminates a line and a single trailing '\r' is stripped, so CRLF dumps +// behave identically. A final line without a trailing newline is still +// delivered. +// +// Mirrors Node's src/lib/validations/line-by-line.ts getReadInterface + +// the perLineValidations dispatch loop in sql.ts. +func ScanLines(r io.Reader, fn func(line string, lineNum int) error) error { + br := bufio.NewReaderSize(r, readBufSize) + + lineNum := 1 + for { + line, err := readLine(br) + if err != nil && err != io.EOF { + return err + } + // At EOF with nothing buffered there is no final partial line. + if err == io.EOF && len(line) == 0 { + return nil + } + if cbErr := fn(string(trimEOL(line)), lineNum); cbErr != nil { + return cbErr + } + lineNum++ + if err == io.EOF { + return nil + } + } +} + +// readLine returns the next '\n'-terminated chunk, growing past the read +// buffer when necessary. The returned slice may alias br's internal buffer +// when the line fit in one read, so callers must copy (ScanLines converts +// to string immediately) before the next read. +func readLine(br *bufio.Reader) ([]byte, error) { + frag, err := br.ReadSlice('\n') + if err != bufio.ErrBufferFull { + return frag, err + } + // Long line: keep pulling fragments until the delimiter (or EOF). + // append copies frag out of br's buffer before it is reused. + buf := append([]byte(nil), frag...) + for err == bufio.ErrBufferFull { + frag, err = br.ReadSlice('\n') + buf = append(buf, frag...) + } + return buf, err +} + +// trimEOL drops the trailing '\n' and an immediately preceding '\r', +// matching bufio.ScanLines and Node's readline /\r?\n/ split. +func trimEOL(b []byte) []byte { + if n := len(b); n > 0 && b[n-1] == '\n' { + b = b[:n-1] + } + if n := len(b); n > 0 && b[n-1] == '\r' { + b = b[:n-1] + } + return b +} diff --git a/internal/sqlvalidation/line_by_line_test.go b/internal/sqlvalidation/line_by_line_test.go new file mode 100644 index 000000000..dff15cc6f --- /dev/null +++ b/internal/sqlvalidation/line_by_line_test.go @@ -0,0 +1,165 @@ +package sqlvalidation + +import ( + "bytes" + "errors" + "strconv" + "strings" + "testing" +) + +func TestScanLinesBasic(t *testing.T) { + in := strings.NewReader("one\ntwo\nthree\n") + var got []string + var nums []int + err := ScanLines(in, func(line string, n int) error { + got = append(got, line) + nums = append(nums, n) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err: %v", err) + } + wantLines := []string{"one", "two", "three"} + wantNums := []int{1, 2, 3} + if len(got) != len(wantLines) { + t.Fatalf("lines len = %d, want %d", len(got), len(wantLines)) + } + for i := range wantLines { + if got[i] != wantLines[i] { + t.Errorf("line[%d] = %q, want %q", i, got[i], wantLines[i]) + } + if nums[i] != wantNums[i] { + t.Errorf("num[%d] = %d, want %d", i, nums[i], wantNums[i]) + } + } +} + +func TestScanLinesPredicateError(t *testing.T) { + in := strings.NewReader("one\ntwo\nthree\n") + sentinel := errors.New("stop") + var seen []string + err := ScanLines(in, func(line string, n int) error { + seen = append(seen, line) + if n == 2 { + return sentinel + } + return nil + }) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want sentinel", err) + } + if len(seen) != 2 { + t.Errorf("processed %d lines, want 2 (scan should stop on predicate err)", len(seen)) + } +} + +func TestScanLinesLargeLine(t *testing.T) { + // 1MB single-line payload — well within the 16MB cap but far past + // bufio's default 64KB token limit. + big := bytes.Repeat([]byte("x"), 1<<20) + in := bytes.NewReader(append(big, '\n')) + count := 0 + err := ScanLines(in, func(line string, _ int) error { + count++ + if len(line) != 1<<20 { + t.Errorf("got line len %d, want %d", len(line), 1<<20) + } + return nil + }) + if err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if count != 1 { + t.Errorf("processed %d lines, want 1", count) + } +} + +// Register 2.17. Node's line-by-line.ts uses fd.readLines(), which has no +// per-line ceiling at all — a `mysqldump --extended-insert` file, mydumper +// output, or a row with a multi-MB LONGTEXT column routinely produces a +// single INSERT line well past 16MB. Go must read it too, and must never +// surface a bufio internal ("bufio.Scanner: token too long") to the user. +func TestScanLinesLineLargerThanFormer16MBCap(t *testing.T) { + const size = 20 << 20 // 20MB — decisively past the old 16MB cap + big := bytes.Repeat([]byte("x"), size) + in := bytes.NewReader(append(big, '\n')) + + count := 0 + gotLen := 0 + err := ScanLines(in, func(line string, _ int) error { + count++ + gotLen = len(line) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err = %v, want nil (Node has no per-line cap)", err) + } + if count != 1 { + t.Fatalf("processed %d lines, want 1", count) + } + if gotLen != size { + t.Errorf("line len = %d, want %d (line was truncated)", gotLen, size) + } +} + +// A long line must not swallow the lines that follow it. +func TestScanLinesResumesAfterOversizeLine(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("first\n") + buf.Write(bytes.Repeat([]byte("y"), 18<<20)) + buf.WriteString("\nlast\n") + + var got []string + err := ScanLines(&buf, func(line string, _ int) error { + if len(line) > 64 { + got = append(got, "<big:"+strconv.Itoa(len(line))+">") + return nil + } + got = append(got, line) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err = %v, want nil", err) + } + want := []string{"first", "<big:" + strconv.Itoa(18<<20) + ">", "last"} + if len(got) != len(want) { + t.Fatalf("got %d lines %v, want %d %v", len(got), got, len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// bufio.Scanner's ScanLines strips a trailing \r; Node's readline splits on +// /\r?\n/ and does the same. CRLF dumps (Windows-authored mysqldump output) +// must keep behaving that way after the Scanner is replaced. +func TestScanLinesStripsCarriageReturn(t *testing.T) { + in := strings.NewReader("one\r\ntwo\r\n") + var got []string + if err := ScanLines(in, func(line string, _ int) error { + got = append(got, line) + return nil + }); err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Errorf("got %#v, want [one two]", got) + } +} + +func TestScanLinesNoTrailingNewline(t *testing.T) { + in := strings.NewReader("one\ntwo") + var got []string + if err := ScanLines(in, func(line string, _ int) error { + got = append(got, line) + return nil + }); err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Errorf("got %#v, want [one two]", got) + } +} diff --git a/internal/sqlvalidation/multisite.go b/internal/sqlvalidation/multisite.go new file mode 100644 index 000000000..af964a1b0 --- /dev/null +++ b/internal/sqlvalidation/multisite.go @@ -0,0 +1,31 @@ +package sqlvalidation + +import "regexp" + +// Multi-site detection regexes — straight port of Node's +// src/lib/validations/is-multi-site-sql-dump.ts. +var ( + // SQL_CREATE_TABLE_IS_MULTISITE_REGEX from is-multi-site-sql-dump.ts:1 + // /^CREATE TABLE(?: IF NOT EXISTS)? `?(wp_\d+_[a-z0-9_]*|wp_blogs)/i + sqlCreateTableIsMultisiteRE = regexp.MustCompile( + `(?i)^CREATE TABLE(?: IF NOT EXISTS)? ` + "`" + `?(wp_\d+_[a-z0-9_]*|wp_blogs)`, + ) + // SQL_CONTAINS_MULTISITE_WP_USERS_REGEX from is-multi-site-sql-dump.ts:3 + // /`spam` tinyint\(2\)|`deleted` tinyint\(2\)/i + sqlContainsMultisiteWPUsersRE = regexp.MustCompile( + "(?i)`spam` tinyint\\(2\\)|`deleted` tinyint\\(2\\)", + ) +) + +// IsMultiSiteSQLDumpLine returns true if the given SQL line is evidence the +// dump comes from a WordPress multisite install. Mirrors Node's +// sqlDumpLineIsMultiSite (is-multi-site-sql-dump.ts). +// +// Two heuristics, OR'd together: +// - CREATE TABLE [IF NOT EXISTS] wp_<N>_<name> OR wp_blogs +// - lines defining the wp_users multisite columns (`spam` tinyint(2), +// `deleted` tinyint(2)) +func IsMultiSiteSQLDumpLine(line string) bool { + return sqlCreateTableIsMultisiteRE.MatchString(line) || + sqlContainsMultisiteWPUsersRE.MatchString(line) +} diff --git a/internal/sqlvalidation/multisite_test.go b/internal/sqlvalidation/multisite_test.go new file mode 100644 index 000000000..89f2097e1 --- /dev/null +++ b/internal/sqlvalidation/multisite_test.go @@ -0,0 +1,34 @@ +package sqlvalidation + +import "testing" + +// Cases mirror Node's __tests__/lib/validations/is-multi-site-sql-dump.js. +func TestIsMultiSiteSQLDumpLine(t *testing.T) { + type tc struct { + line string + want bool + } + cases := []tc{ + // True: multisite CREATE TABLE lines. + {"CREATE TABLE wp_2_posts", true}, + {"CREATE TABLE wp_23_posts", true}, + {"CREATE TABLE wp_2345235_posts", true}, + {"CREATE TABLE wp_blogs", true}, + {"CREATE TABLE IF NOT EXISTS wp_2_posts", true}, + // False: single-site CREATE TABLE. + {"CREATE TABLE wp_posts", false}, + {"CREATE TABLE IF NOT EXISTS wp_posts", false}, + // True: multisite wp_users columns. + {"`spam` tinyint(2) NOT NULL DEFAULT 0,", true}, + {"`deleted` tinyint(2) NOT NULL DEFAULT 0,", true}, + // Case-insensitive parity. + {"create table wp_5_options", true}, + // Empty. + {"", false}, + } + for _, c := range cases { + if got := IsMultiSiteSQLDumpLine(c.line); got != c.want { + t.Errorf("IsMultiSiteSQLDumpLine(%q) = %v, want %v", c.line, got, c.want) + } + } +} diff --git a/internal/sqlvalidation/sql.go b/internal/sqlvalidation/sql.go new file mode 100644 index 000000000..7505298d6 --- /dev/null +++ b/internal/sqlvalidation/sql.go @@ -0,0 +1,510 @@ +package sqlvalidation + +import ( + "io" + "os" + "regexp" + "strings" +) + +// FormatterKind classifies how a check's accumulated results are rendered +// in the validate-sql summary. Mirrors the four outputFormatter closures +// in Node's src/lib/validations/sql.ts: +// +// - FormatterLineNumber: lineNumberCheckFormatter — joins lineNumbers and +// emits "<message> on line(s) X, Y, Z."; "<message> was found 0 times." +// when empty. +// - FormatterRequired: requiredCheckFormatter — inverts: 0 results is the +// PROBLEM ("<message> was not found."). For createTable, also runs the +// wp_ / wp_<n>_ prefix sub-classifier. +// - FormatterInfo: infoCheckFormatter — pushes every result.Text as an +// info line; never produces an error. +// - FormatterGeneral: generalCheckFormatter — drops FalsePositive results, +// then emits one line PER surviving result ("<message> on line N.", +// singular) with that result's own Recommendation when it has one. +// +// FormatterGeneral is used only by siteHomeUrlLando, which validate-sql +// always skips (DEV_ENV_SPECIFIC_CHECKS). Its only consumer is the dev-env +// import renderer in internal/devenv; the platform renderer in +// cmd/vip-next/commands/sqlreport.go never sees it. +type FormatterKind int + +const ( + FormatterLineNumber FormatterKind = iota + FormatterRequired + FormatterInfo + FormatterGeneral +) + +// Check keys that Node lists in DEV_ENV_SPECIFIC_CHECKS (sql.ts:394). They +// are skipped by every platform caller and registered only by the dev-env +// import path. +const ( + CheckUseStatement = "useStatement" + CheckSiteHomeURLLando = "siteHomeUrlLando" +) + +// DevEnvSpecificChecks ports DEV_ENV_SPECIFIC_CHECKS (sql.ts:394). It is the +// skipChecks value of Node's DEFAULT_VALIDATION_OPTIONS (sql.ts:522-526), so +// every platform entry point (`vip import validate-sql`, `vip import sql`) +// omits both checks. Node's dev-env import OVERRIDES skipChecks with `[]`, +// which is what turns them on there. +var DevEnvSpecificChecks = []string{CheckUseStatement, CheckSiteHomeURLLando} + +// Options mirrors Node's ValidationOptions (sql.ts:68-75) minus isImport, +// which is a rendering concern the callers own. +type Options struct { + // SkipChecks lists check keys to leave unregistered. + SkipChecks []string + // ExtraCheckParams supplies the third argument Node threads into + // matchHandler (sql.ts:544), keyed by check name. Only siteHomeUrlLando + // reads one: the expected local domain. + ExtraCheckParams map[string]string +} + +// PlatformOptions is the port of DEFAULT_VALIDATION_OPTIONS (sql.ts:522): +// skip the two dev-env-specific checks, no extra params. Every platform +// caller must use this (Validate/ValidateFile already do). +func PlatformOptions() Options { + return Options{SkipChecks: DevEnvSpecificChecks} +} + +// CheckResult holds one match captured from a single SQL line. Fields are +// optional — different formatters consume different fields: +// +// - Line: 1-indexed line number where the match was captured. +// - Text: the captured text (table name for dropTable/createTable, raw +// match for siteHomeUrl). +// - FalsePositive: Node's `falsePositive` — the matchHandler looked at the +// match and decided it is not a finding after all. Only siteHomeUrlLando +// sets it. FalsePositive results are dropped before rendering. +// - Recommendation: Node's `recomendation` (sic) — a per-result override of +// the check's own Recommendation. siteHomeUrlLando uses it to name the +// exact --search-replace flag that fixes THIS line. +// +// Node's fourth per-result field, `warning`, is deliberately NOT ported. +// Severity for the dev-env path is owned by the single tier table in +// internal/devenv/importvalidate.go, so there is exactly one place to look. +type CheckResult struct { + Line int + Text string + FalsePositive bool + Recommendation string +} + +// Check mirrors Node's CheckType. Identity is the key it's filed under in +// the checks map (binaryLogging, trigger, etc.); name is the human-readable +// label used in the rendered output. +type Check struct { + Key string // identity (binaryLogging, trigger, ...) + Matcher *regexp.Regexp // compiled from Node's `matcher` field + Message string // Node's `message` + Recommendation string // Node's `recommendation` + Formatter FormatterKind // outputFormatter family + // MatchHandler decides what to record from a successful match. Mirrors + // Node's matchHandler arrow, including its third argument: the + // per-check extraParam from Options.ExtraCheckParams (sql.ts:544). + // Only siteHomeUrlLando reads it; every other handler ignores it. + MatchHandler func(lineNum int, matches []string, extraParam string) CheckResult + Results []CheckResult // accumulated during Validate() +} + +// Result is the full output of Validate(). Order is deterministic: Checks +// retains insertion order, matching Node's Object.entries(checks) iteration +// order (V8 preserves insertion order for non-numeric string keys). +type Result struct { + Checks []*Check + TableNames []string // captured by checkForTableName; used for duplicate detection + IsMultiSite bool // OR of IsMultiSiteSQLDumpLine across every line + LinesProcessed int +} + +// newChecks constructs the check set for opts. Order mirrors Node's +// `checks` object literal in sql.ts:250, which is also its iteration order +// (V8 preserves insertion order for non-numeric string keys). Keys named in +// opts.SkipChecks are not registered, mirroring Node's filter in +// perLineValidations (sql.ts:538) and postValidation (sql.ts:418). +// +// Every regex is compiled from the Node source verbatim with the same flags +// (Go: `(?i)` for case-insensitive). I/O patterns where Node uses a string +// matcher (passed to String.prototype.match which silently wraps it in a +// dynamic RegExp) are translated to a Go RegExp here. +func newChecks(opts Options) []*Check { + skip := make(map[string]bool, len(opts.SkipChecks)) + for _, key := range opts.SkipChecks { + skip[key] = true + } + out := make([]*Check, 0, len(allChecks())) + for _, c := range allChecks() { + if !skip[c.Key] { + out = append(out, c) + } + } + return out +} + +// allChecks builds every check Node declares, in Node's declaration order. +// Callers filter it via newChecks. +func allChecks() []*Check { + return []*Check{ + // sql.ts:251-259 — binaryLogging + // matcher: /SET @@SESSION.sql_log_bin/i + { + Key: "binaryLogging", + Matcher: regexp.MustCompile(`(?i)SET @@SESSION.sql_log_bin`), + Message: "SET @@SESSION.sql_log_bin statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:260-270 — trigger + // /^CREATE (\(?DEFINER=`?(\w*)(`@`)?(\w*\.*%?)*`?\)?)?(| )TRIGGER/i + // Go's regexp (RE2) handles this directly. + { + Key: "trigger", + Matcher: regexp.MustCompile("(?i)^CREATE (\\(?DEFINER=`?(\\w*)(`@`)?(\\w*\\.*%?)*`?\\)?)?(| )TRIGGER"), + Message: "TRIGGER statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:271-279 — dropDB + // /^DROP DATABASE/i + { + Key: "dropDB", + Matcher: regexp.MustCompile(`(?i)^DROP DATABASE`), + Message: "DROP DATABASE statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:280-288 — useStatement. DEV_ENV_SPECIFIC_CHECKS, so every + // platform caller skips it; the dev-env import registers it because + // a `USE <db>` in the dump would point the import at a database + // other than the environment's own. + // /^USE /i + { + Key: CheckUseStatement, + Matcher: regexp.MustCompile(`(?i)^USE `), + Message: "USE <DATABASE_NAME> statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:289-297 — alterUser + // /^(ALTER USER|SET PASSWORD)/i + { + Key: "alterUser", + Matcher: regexp.MustCompile(`(?i)^(ALTER USER|SET PASSWORD)`), + Message: "ALTER USER statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:298-306 — dropTable + // /^DROP TABLE IF EXISTS `?([a-z0-9_]*)/i + // matchHandler: results[1] -> {text: tableName} + { + Key: "dropTable", + Matcher: regexp.MustCompile("(?i)^DROP TABLE IF EXISTS `?([a-z0-9_]*)"), + Message: "DROP TABLE", + Recommendation: "Check import settings to include DROP TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + // sql.ts:307-315 — createTable + // /^CREATE TABLE (?:IF NOT EXISTS )?`?([a-z0-9_]*)/i + // matchHandler: results[1] -> {text: tableName} + { + Key: "createTable", + Matcher: regexp.MustCompile("(?i)^CREATE TABLE (?:IF NOT EXISTS )?`?([a-z0-9_]*)"), + Message: "CREATE TABLE", + Recommendation: "Check import settings to include CREATE TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + // sql.ts:316-325 — alterTable + // /^ALTER TABLE `?([a-z0-9_]*)/i + { + Key: "alterTable", + Matcher: regexp.MustCompile("(?i)^ALTER TABLE `?([a-z0-9_]*)"), + Message: "ALTER TABLE statement", + Recommendation: "Remove these lines and define table structure in the " + + "CREATE TABLE statement instead", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:326-334 — uniqueChecks + // /^SET UNIQUE_CHECKS\s*=\s*0/i + { + Key: "uniqueChecks", + Matcher: regexp.MustCompile(`(?i)^SET UNIQUE_CHECKS\s*=\s*0`), + Message: "SET UNIQUE_CHECKS = 0", + Recommendation: "Disabling 'UNIQUE_CHECKS' is not allowed. These lines should be removed", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:335-343 — siteHomeUrl + // matcher: `['"](siteurl|home)['"],\\s?['"](.*?)['"]` (string -> + // dynamic RegExp; no /i flag, so case-sensitive in Node) + // matchHandler: {text: results[1] + ' ' + results[2]} + { + Key: "siteHomeUrl", + Matcher: regexp.MustCompile(`['"](siteurl|home)['"],\s?['"](.*?)['"]`), + Message: "Siteurl/home matches", + Recommendation: "", + Formatter: FormatterInfo, + MatchHandler: handlerSiteHomeURL, + }, + // sql.ts:344-369 — siteHomeUrlLando. DEV_ENV_SPECIFIC_CHECKS, so the + // platform never registers it. For dev-env it is the highest-value + // check in the file: it catches a production dump whose siteurl/home + // still points at production, which after import leaves the LOCAL + // site redirecting to the live site. + // + // matcher: `['"](siteurl|home)['"],\\s?['"]([^'"]+)['"]` + // (a STRING matcher -> dynamic RegExp with NO /i flag, so the + // option name is matched case-sensitively — unlike most checks.) + // + // NOTE Node marks every finding here `warning: true`, which makes + // generalCheckFormatter skip `problemsFound += 1` — i.e. Node WARNS + // and imports anyway. vip-next treats it as fatal; that severity + // decision lives in the tier table in internal/devenv/importvalidate.go, + // not here. + { + Key: CheckSiteHomeURLLando, + Matcher: regexp.MustCompile(`['"](siteurl|home)['"],\s?['"]([^'"]+)['"]`), + Message: "Siteurl/home options not pointing to lando domain", + Recommendation: "Use search-replace to change environment's domain", + Formatter: FormatterGeneral, + MatchHandler: handlerSiteHomeURLLando, + }, + // sql.ts:370-380 — engineInnoDB + // /\sENGINE\s?=(?!(\s?InnoDB))/i — has negative lookahead, NOT + // supported by RE2. Express the same intent with two-step matching: + // match ENGINE= then check the following token is NOT 'InnoDB'. + // See engineInnoDBMatcher below for the override. + { + Key: "engineInnoDB", + Matcher: nil, // sentinel: dispatch uses engineInnoDBMatch directly + Message: "ENGINE != InnoDB", + Recommendation: "Ensure your application works with InnoDB and update your SQL " + + "dump to include only 'ENGINE=InnoDB' engine definitions in 'CREATE TABLE' " + + "statements. We suggest you search for all 'ENGINE=X' entries and replace " + + "them with 'ENGINE=InnoDB'!", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:381-391 — autoIncrement + // /\s(NOT NULL AUTO_INCREMENT,)/i + // matchHandler: {text: results[1]} + { + Key: "autoIncrement", + Matcher: regexp.MustCompile(`(?i)\s(NOT NULL AUTO_INCREMENT,)`), + Message: "AUTO_INCREMENT attribute", + Recommendation: "Check import settings to include AUTO_INCREMENT attribute in all " + + "the CREATE TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + } +} + +// handlerLine is the lineNumber matchHandler used by 8 of the checks. +func handlerLine(lineNum int, _ []string, _ string) CheckResult { + return CheckResult{Line: lineNum} +} + +// handlerText1 captures results[1] from the match. Used by dropTable, +// createTable, autoIncrement. +func handlerText1(_ int, matches []string, _ string) CheckResult { + if len(matches) < 2 { + return CheckResult{} + } + return CheckResult{Text: matches[1]} +} + +// handlerSiteHomeURL builds "<key> <value>" from results[1] and results[2]. +// Used by siteHomeUrl. +func handlerSiteHomeURL(_ int, matches []string, _ string) CheckResult { + if len(matches) < 3 { + return CheckResult{} + } + return CheckResult{Text: matches[1] + " " + matches[2]} +} + +// httpSchemePrefix / httpSchemePrefixCI port the TWO different regexes Node +// uses back-to-back on the same value in siteHomeUrlLando's matchHandler +// (sql.ts:348 and :351): the guard test is case-INsensitive, the strip is +// case-SENSITIVE. That asymmetry is Node's, and it is load-bearing for the +// output: `HTTP://EXAMPLE.COM` passes the guard but keeps its scheme through +// the strip, so the recommendation Node prints (and we print) names +// `HTTP://EXAMPLE.COM` rather than the bare host. Ported verbatim rather than +// "fixed" so both CLIs recommend the same --search-replace string. +var ( + httpSchemePrefixCI = regexp.MustCompile(`(?i)^https?://`) + httpSchemePrefix = regexp.MustCompile(`^https?://`) +) + +// handlerSiteHomeURLLando ports sql.ts:346-363. extraParam is the expected +// local domain ("<slug>.<domain>"); an empty one would make every absolute +// URL a finding, so callers must supply it. +func handlerSiteHomeURLLando(lineNum int, matches []string, expectedDomain string) CheckResult { + if len(matches) < 3 { + return CheckResult{FalsePositive: true} + } + found := matches[2] + // Node: if ( ! /^https?:\/\//i.test( foundDomain ) ) return falsePositive + if !httpSchemePrefixCI.MatchString(found) { + return CheckResult{FalsePositive: true} + } + // Node: foundDomain = foundDomain.replace( /^https?:\/\//, '' ) + found = httpSchemePrefix.ReplaceAllString(found, "") + // Node: if ( ! foundDomain.trim() ) return falsePositive + if strings.TrimSpace(found) == "" { + return CheckResult{FalsePositive: true} + } + // Node: if ( foundDomain.includes( expectedDomain ) ) return falsePositive + if strings.Contains(found, expectedDomain) { + return CheckResult{FalsePositive: true} + } + return CheckResult{ + Line: lineNum, + Recommendation: `Use '--search-replace="` + found + "," + expectedDomain + `"' switch to replace the domain`, + } +} + +// engineInnoDBHasMatch checks whether a line should be flagged as +// non-InnoDB. Replicates Node's /\sENGINE\s?=(?!(\s?InnoDB))/i which uses +// a negative lookahead RE2 cannot express. We approximate: find every +// `<space>ENGINE<optional space>=` occurrence and inspect what follows. +var engineInnoDBPrefix = regexp.MustCompile(`(?i)\sENGINE\s?=`) + +func engineInnoDBMatch(line string) bool { + indexes := engineInnoDBPrefix.FindAllStringIndex(line, -1) + for _, idx := range indexes { + tail := line[idx[1]:] + // Node's negative lookahead: NOT followed by (optional space + "InnoDB") + trimmed := tail + if len(trimmed) > 0 && trimmed[0] == ' ' { + trimmed = trimmed[1:] + } + if !strings.HasPrefix(strings.ToLower(trimmed), "innodb") { + return true + } + } + return false +} + +// checkForTableNamePattern mirrors sql.ts:514 — captures the wp_-prefixed +// table name from a CREATE TABLE line: +// +// /(?<=^CREATE\sTABLE\s)`?(?:(wp_[\d+_]?\w+))`?/ +// +// RE2 lacks lookbehind; we anchor on ^CREATE TABLE and use a capturing +// group instead. The Node regex is case-sensitive (no /i flag). +// +// Bug-for-bug parity note: the `[\d+_]?` character class in Node almost +// certainly was meant to be `(\d+_)?` (a digit-run followed by underscore, +// the multisite-prefix shape). Inside a character class the `+` is a +// literal `+`, not a quantifier. We mirror the Node regex verbatim so the +// output stays byte-identical; do NOT "fix" this character class without +// also updating Node upstream — see vip-cli sql.ts:514. +var checkForTableNamePattern = regexp.MustCompile( + "^CREATE TABLE `?(wp_[\\d+_]?\\w+)`?", +) + +func checkForTableName(line string) (string, bool) { + m := checkForTableNamePattern.FindStringSubmatch(line) + if m == nil || len(m) < 2 { + return "", false + } + return m[1], true +} + +// Validate scans r line-by-line and returns the accumulated check results +// + table-name list + multisite flag. Mirrors Node's validate() body in +// sql.ts:570 (minus the post-validation reporting pass, which the handler +// performs against this Result). +// +// PLATFORM semantics: isImport=false, skipChecks=DEV_ENV_SPECIFIC_CHECKS, +// extraCheckParams={} — i.e. PlatformOptions(). `vip import validate-sql` +// and `vip import sql` must keep using this (or ValidateWithLineHook), which +// is what guarantees they never run useStatement or siteHomeUrlLando. +func Validate(r io.Reader) (*Result, error) { + return ValidateWithLineHook(r, nil) +} + +// ValidateWithLineHook is Validate with an optional per-line callback, +// letting `vip import sql` run its site-type capture (wp_site INSERT +// statements, multisite heuristics) and the "Reading line N" ticker in +// the same streaming pass Node's fileLineValidations performs +// (line-by-line.ts:51 dispatches every registered validation per line). +func ValidateWithLineHook(r io.Reader, hook func(line string, lineNum int)) (*Result, error) { + return ValidateWith(r, PlatformOptions(), hook) +} + +// ValidateWith is the general entry point: it honours opts.SkipChecks and +// opts.ExtraCheckParams. The dev-env import path uses it to register the two +// DEV_ENV_SPECIFIC_CHECKS Node turns on there (dev-env-import-sql.ts:96). +func ValidateWith(r io.Reader, opts Options, hook func(line string, lineNum int)) (*Result, error) { + res := &Result{Checks: newChecks(opts)} + + err := ScanLines(r, func(line string, lineNum int) error { + if hook != nil { + hook(line, lineNum) + } + res.LinesProcessed = lineNum + + // Multi-site detection: OR'd across every line, like Node's separate + // pass over the dump in callers that use sqlDumpLineIsMultiSite. + if !res.IsMultiSite && IsMultiSiteSQLDumpLine(line) { + res.IsMultiSite = true + } + + // Per Node's checkForTableName (sql.ts:513), only the wp_-prefixed + // CREATE TABLE name is captured into tableNames for duplicate + // detection — not every table. + if name, ok := checkForTableName(line); ok { + res.TableNames = append(res.TableNames, name) + } + + for _, check := range res.Checks { + extraParam := opts.ExtraCheckParams[check.Key] + // engineInnoDB uses a custom matcher because Node's pattern uses + // a negative lookahead RE2 can't express directly. + if check.Key == "engineInnoDB" { + if engineInnoDBMatch(line) { + check.Results = append(check.Results, check.MatchHandler(lineNum, nil, extraParam)) + } + continue + } + m := check.Matcher.FindStringSubmatch(line) + if m != nil { + check.Results = append(check.Results, check.MatchHandler(lineNum, m, extraParam)) + } + } + return nil + }) + if err != nil { + return nil, err + } + return res, nil +} + +// ValidateFile opens path and delegates to Validate (platform semantics). +// The caller is responsible for surfacing open errors with the Node-parity +// wording. +func ValidateFile(path string) (*Result, error) { + return ValidateFileWith(path, PlatformOptions()) +} + +// ValidateFileWith opens path and delegates to ValidateWith. +func ValidateFileWith(path string, opts Options) (*Result, error) { + f, err := os.Open(path) // #nosec G304 -- path is a user-supplied CLI arg + if err != nil { + return nil, err + } + defer f.Close() + return ValidateWith(f, opts, nil) +} diff --git a/internal/sqlvalidation/sql_test.go b/internal/sqlvalidation/sql_test.go new file mode 100644 index 000000000..bc17e9b3a --- /dev/null +++ b/internal/sqlvalidation/sql_test.go @@ -0,0 +1,206 @@ +package sqlvalidation + +import ( + "strings" + "testing" +) + +// findCheck returns the named check from the Result. Test helper. +func findCheck(t *testing.T, res *Result, key string) *Check { + t.Helper() + for _, c := range res.Checks { + if c.Key == key { + return c + } + } + t.Fatalf("check %q not found", key) + return nil +} + +func TestValidateBinaryLogging(t *testing.T) { + in := strings.NewReader("SET @@SESSION.sql_log_bin = 1;\n") + res, err := Validate(in) + if err != nil { + t.Fatalf("Validate: %v", err) + } + c := findCheck(t, res, "binaryLogging") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("binaryLogging: got %#v, want [{Line:1}]", c.Results) + } +} + +func TestValidateTrigger(t *testing.T) { + in := strings.NewReader("CREATE DEFINER=`root`@`localhost` TRIGGER my_trigger BEFORE INSERT\n") + res, _ := Validate(in) + c := findCheck(t, res, "trigger") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("trigger: got %#v", c.Results) + } +} + +func TestValidateDropDatabase(t *testing.T) { + in := strings.NewReader("DROP DATABASE foo;\n") + res, _ := Validate(in) + c := findCheck(t, res, "dropDB") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("dropDB: got %#v", c.Results) + } +} + +func TestValidateAlterUser(t *testing.T) { + cases := []string{ + "ALTER USER 'root'@'localhost' IDENTIFIED BY 'x';\n", + "SET PASSWORD FOR 'root'@'localhost' = 'x';\n", + } + for _, sql := range cases { + res, _ := Validate(strings.NewReader(sql)) + c := findCheck(t, res, "alterUser") + if len(c.Results) != 1 { + t.Errorf("alterUser %q: got %d results", sql, len(c.Results)) + } + } +} + +func TestValidateDropTable(t *testing.T) { + in := strings.NewReader("DROP TABLE IF EXISTS `wp_users`;\n") + res, _ := Validate(in) + c := findCheck(t, res, "dropTable") + if len(c.Results) != 1 || c.Results[0].Text != "wp_users" { + t.Errorf("dropTable: got %#v", c.Results) + } +} + +func TestValidateCreateTable(t *testing.T) { + in := strings.NewReader("CREATE TABLE `wp_users` (id int);\n") + res, _ := Validate(in) + c := findCheck(t, res, "createTable") + if len(c.Results) != 1 || c.Results[0].Text != "wp_users" { + t.Errorf("createTable: got %#v", c.Results) + } + if len(res.TableNames) != 1 || res.TableNames[0] != "wp_users" { + t.Errorf("tableNames: got %#v, want [wp_users]", res.TableNames) + } +} + +func TestValidateAlterTable(t *testing.T) { + in := strings.NewReader("ALTER TABLE `wp_users` ADD COLUMN x INT;\n") + res, _ := Validate(in) + c := findCheck(t, res, "alterTable") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("alterTable: got %#v", c.Results) + } +} + +func TestValidateUniqueChecks(t *testing.T) { + in := strings.NewReader("SET UNIQUE_CHECKS = 0;\n") + res, _ := Validate(in) + c := findCheck(t, res, "uniqueChecks") + if len(c.Results) != 1 { + t.Errorf("uniqueChecks: got %#v", c.Results) + } +} + +func TestValidateSiteHomeUrl(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'siteurl','http://example.com');` + "\n") + res, _ := Validate(in) + c := findCheck(t, res, "siteHomeUrl") + if len(c.Results) != 1 || c.Results[0].Text != "siteurl http://example.com" { + t.Errorf("siteHomeUrl: got %#v", c.Results) + } +} + +func TestValidateEngineInnoDB(t *testing.T) { + // Non-InnoDB engine should be flagged. + in := strings.NewReader(") ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;\n") + res, _ := Validate(in) + c := findCheck(t, res, "engineInnoDB") + if len(c.Results) != 1 { + t.Errorf("engineInnoDB MyISAM: got %d results, want 1", len(c.Results)) + } + + // InnoDB should NOT be flagged. + in = strings.NewReader(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n") + res, _ = Validate(in) + c = findCheck(t, res, "engineInnoDB") + if len(c.Results) != 0 { + t.Errorf("engineInnoDB InnoDB: got %d results, want 0", len(c.Results)) + } + + // Case-insensitive ENGINE=, and an optional space before InnoDB are OK. + in = strings.NewReader(") engine= InnoDB DEFAULT CHARSET=utf8mb4;\n") + res, _ = Validate(in) + c = findCheck(t, res, "engineInnoDB") + if len(c.Results) != 0 { + t.Errorf("engineInnoDB case-insensitive: got %d results, want 0", len(c.Results)) + } +} + +func TestValidateAutoIncrement(t *testing.T) { + in := strings.NewReader(" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n") + res, _ := Validate(in) + c := findCheck(t, res, "autoIncrement") + if len(c.Results) != 1 || c.Results[0].Text != "NOT NULL AUTO_INCREMENT," { + t.Errorf("autoIncrement: got %#v", c.Results) + } +} + +func TestValidateMultiSiteDetection(t *testing.T) { + in := strings.NewReader("CREATE TABLE wp_2_options (id int);\n") + res, _ := Validate(in) + if !res.IsMultiSite { + t.Errorf("IsMultiSite: got false, want true") + } +} + +func TestValidateCleanDump(t *testing.T) { + clean := strings.Join([]string{ + "-- A clean dump.", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + "INSERT INTO `wp_options` VALUES (1, 'siteurl', 'http://example.com');", + }, "\n") + "\n" + res, err := Validate(strings.NewReader(clean)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if res.IsMultiSite { + t.Errorf("clean dump flagged multisite") + } + // dropTable and createTable present; everything else absent. + if got := len(findCheck(t, res, "dropTable").Results); got != 1 { + t.Errorf("dropTable: got %d, want 1", got) + } + if got := len(findCheck(t, res, "createTable").Results); got != 1 { + t.Errorf("createTable: got %d, want 1", got) + } + for _, key := range []string{"binaryLogging", "trigger", "dropDB", "alterUser", "alterTable", "uniqueChecks", "engineInnoDB"} { + if got := len(findCheck(t, res, key).Results); got != 0 { + t.Errorf("%s in clean dump: got %d, want 0", key, got) + } + } +} + +func TestValidateUseStatementSkipped(t *testing.T) { + // useStatement is in DEV_ENV_SPECIFIC_CHECKS and never registered for + // validate-sql; a USE statement should not appear in any check's results. + in := strings.NewReader("USE my_database;\n") + res, _ := Validate(in) + for _, c := range res.Checks { + if c.Key == "useStatement" { + t.Fatalf("useStatement should not be registered for validate-sql") + } + if len(c.Results) != 0 { + t.Errorf("%s flagged USE line: %#v", c.Key, c.Results) + } + } +} + +func TestValidateFileMissing(t *testing.T) { + _, err := ValidateFile("/nonexistent/path/that/should/not/exist.sql") + if err == nil { + t.Errorf("ValidateFile(missing): got nil, want error") + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 000000000..afedf379d --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,246 @@ +// Package sync wraps the SyncEnvironment + SyncProgress genqlient +// operations behind a Go-friendly surface. +// +// The package intentionally collides with stdlib `sync`; callers should +// import it under an alias (e.g. `syncpkg`). +// +// Node parity references: src/bin/vip-sync.js. Notable schema facts +// discovered while porting: +// +// - AppEnvironmentSyncInput uses Id (not appId) for the application ID. +// - AppEnvironmentSyncProgress.sync is Int (the job ID), not String. +// - AppEnvironmentSyncStep has three fields — Name, Status, Step — +// where Step is the stable identifier (the plan only listed two). +// +// The "Site is already syncing" GraphQL error is treated specially: +// Start returns an AlreadySyncingError sentinel so the handler can +// proceed to polling without surfacing the message as a fatal error +// (mirrors Node's CombinedGraphQLErrors check). +package sync + +import ( + "context" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// AlreadySyncingErrMsg is the exact server error string that signals an +// in-progress sync. Compared as a substring (not equality) because the +// transport may wrap the message with location metadata; Node's check +// uses exact equality against err.message, but matching as substring is +// strictly more permissive and keeps us robust against minor wrapping. +const AlreadySyncingErrMsg = "Site is already syncing" + +// AlreadySyncingError is returned by Start when the server rejects the +// mutation because a sync is already underway. Callers detect this with +// errors.As / errors.Is to switch to the "join the existing run" path. +type AlreadySyncingError struct{} + +func (AlreadySyncingError) Error() string { return AlreadySyncingErrMsg } + +// Status constants — string values that come back from the API. These +// are the only states currently observed in production; "unknown" is a +// client-side marker for per-step status values not in this list. +const ( + StatusRunning = "running" + StatusSuccess = "success" + StatusFailed = "failed" + StatusPending = "pending" +) + +// Step is the flat per-step view of an in-flight sync. +type Step struct { + Name string + Status string + Step string +} + +// Progress is the overall sync state. Sync is the job ID (Int in the +// schema, despite the plan calling it String). +type Progress struct { + Status string + Sync int64 + Steps []Step +} + +// IsTerminal reports whether the status string is a terminal state +// (success or failed). Running and pending are not terminal. +func IsTerminal(status string) bool { + return status == StatusSuccess || status == StatusFailed +} + +// Start triggers a sync of the production env into the target env. +// On "Site is already syncing", returns AlreadySyncingError so the +// caller can fall through to polling. Other GraphQL errors are +// returned verbatim. +// +// The provided ctx MUST opt out of the error middleware via +// gql.WithAllowGQLErrors so the middleware does not Exit(1) on the +// "already syncing" response before this function sees it. +func Start(ctx context.Context, c graphql.Client, appID, envID int64) error { + id := appID + envIDLocal := envID + input := &gql.AppEnvironmentSyncInput{ + Id: id, + EnvironmentId: envIDLocal, + } + _, err := gql.SyncEnvironment(ctx, c, input) + if err == nil { + return nil + } + if isAlreadySyncing(err) { + return AlreadySyncingError{} + } + return err +} + +// isAlreadySyncing returns true if the err chain contains the +// "Site is already syncing" message. genqlient surfaces server errors +// as a gqlerror.List whose Error() concatenates each .Message; we +// substring-match instead of poking at the list directly to keep this +// resilient to genqlient internals. +func isAlreadySyncing(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), AlreadySyncingErrMsg) +} + +// Status returns the current sync progress for (appID, envID). Returns +// (nil, nil) when the server response shape is present but lacks a +// syncProgress block (e.g. immediately after Start fires, before the +// background job kicks in). +func Status(ctx context.Context, c graphql.Client, appID, envID int64) (*Progress, error) { + resp, err := gql.SyncProgress(ctx, c, appID, envID) + if err != nil { + return nil, err + } + if resp == nil || resp.App == nil { + return nil, nil + } + for _, e := range resp.App.Environments { + if e == nil { + continue + } + // Defensive: server-side query already filters by envID, but a future + // caller (or schema change) might surface multiple envs in the slice. + // Match explicitly so we never return a sibling env's progress. + // If the server omits id (nullable scalar), accept the first env — + // matching pre-filter behavior so test fixtures aren't forced to + // echo the id back. + if e.Id != nil && *e.Id != envID { + continue + } + if e.SyncProgress == nil { + return nil, nil + } + p := &Progress{} + if e.SyncProgress.Status != nil { + p.Status = *e.SyncProgress.Status + } + if e.SyncProgress.Sync != nil { + p.Sync = *e.SyncProgress.Sync + } + for _, s := range e.SyncProgress.Steps { + if s == nil { + continue + } + step := Step{} + if s.Name != nil { + step.Name = *s.Name + } + if s.Status != nil { + step.Status = *s.Status + } + if s.Step != nil { + step.Step = *s.Step + } + p.Steps = append(p.Steps, step) + } + return p, nil + } + return nil, nil +} + +// PollOpts configures the Poll loop. +type PollOpts struct { + // Interval between Status queries. Zero falls back to DefaultInterval. + Interval time.Duration + // OnTransition, if non-nil, is invoked exactly once per step on each + // observed status change (including the first time the step is seen). + // The argument is the step's NEW state. + OnTransition func(Step) + // OnError, if non-nil, is consulted on transient Status errors. Return + // true to keep polling (treat as transient), false to abort the loop + // with the error. + OnError func(error) bool +} + +// DefaultInterval is the production poll cadence. Tests can override via +// PollOpts.Interval (or, at the handler level, VIP_SYNC_INTERVAL_MS). +const DefaultInterval = 5 * time.Second + +// Poll calls Status on a tick and returns when the sync reaches a +// terminal state (success or failed), the context is cancelled, or an +// error is judged fatal by OnError. The first Status call happens +// immediately (no leading sleep), so callers see step transitions +// without waiting one full Interval first. +// +// Footgun: OnError = func(error) bool { return true } + a context with +// no deadline = infinite silent retry loop. The handler in vip sync uses +// that pairing intentionally for Node parity (Node's setInterval ignores +// poll errors), but it relies on the user being there to hit Ctrl-C. +// Callers without an interactive user MUST pass a context with a +// timeout, OR set OnError to a function that returns false after N +// consecutive failures. +func Poll(ctx context.Context, c graphql.Client, appID, envID int64, opts PollOpts) (*Progress, error) { + interval := opts.Interval + if interval <= 0 { + interval = DefaultInterval + } + // Track the last-seen status per step (by Step identifier when + // available, else falling back to Name). Allows the loop to fire + // OnTransition only on actual change rather than once per tick. + seen := map[string]string{} + + keyOf := func(s Step) string { + if s.Step != "" { + return s.Step + } + return s.Name + } + + for { + p, err := Status(ctx, c, appID, envID) + if err != nil { + if opts.OnError != nil && opts.OnError(err) { + // Transient — sleep and retry. + } else { + return nil, err + } + } else if p != nil { + if opts.OnTransition != nil { + for _, s := range p.Steps { + k := keyOf(s) + if prev, ok := seen[k]; !ok || prev != s.Status { + seen[k] = s.Status + opts.OnTransition(s) + } + } + } + if IsTerminal(p.Status) { + return p, nil + } + } + + select { + case <-ctx.Done(): + return p, ctx.Err() + case <-time.After(interval): + } + } +} diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go new file mode 100644 index 000000000..46d02b7a9 --- /dev/null +++ b/internal/sync/sync_test.go @@ -0,0 +1,253 @@ +package sync + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" +) + +// syncStub is a per-operation GraphQL fake. Each operation is keyed by +// the JSON request's operationName; the value is either a static body +// or a function that returns the body for the i-th hit (0-indexed). +type syncStub struct { + mu sync.Mutex + bodies map[string]func(int) string + hits map[string]int + defaultBody string +} + +func newStub() *syncStub { + return &syncStub{ + bodies: map[string]func(int) string{}, + hits: map[string]int{}, + defaultBody: `{"data":null}`, + } +} + +func (s *syncStub) setStatic(op, body string) { + s.bodies[op] = func(int) string { return body } +} + +func (s *syncStub) setSeq(op string, bodies ...string) { + s.bodies[op] = func(i int) string { + if i >= len(bodies) { + return bodies[len(bodies)-1] + } + return bodies[i] + } +} + +func (s *syncStub) start(t *testing.T) (*httptest.Server, graphql.Client) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + op := extractOp(string(buf)) + + s.mu.Lock() + fn := s.bodies[op] + i := s.hits[op] + s.hits[op] = i + 1 + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if fn == nil { + _, _ = w.Write([]byte(s.defaultBody)) + return + } + _, _ = w.Write([]byte(fn(i))) + })) + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + return srv, c +} + +// extractOp finds the operationName value in a JSON GraphQL request +// body without parsing the whole document. Substring-search is enough +// for tests. +func extractOp(body string) string { + const key = `"operationName":"` + i := strings.Index(body, key) + if i < 0 { + return "" + } + rest := body[i+len(key):] + j := strings.Index(rest, `"`) + if j < 0 { + return "" + } + return rest[:j] +} + +func TestStartHappyPath(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":{"syncEnvironment":{"environment":{"id":7}}}}`) + srv, c := stub.start(t) + defer srv.Close() + + if err := Start(context.Background(), c, 42, 7); err != nil { + t.Fatalf("Start: %v", err) + } +} + +func TestStartAlreadySyncing(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":null,"errors":[{"message":"Site is already syncing"}]}`) + srv, c := stub.start(t) + defer srv.Close() + + err := Start(context.Background(), c, 42, 7) + if err == nil { + t.Fatal("expected AlreadySyncingError, got nil") + } + var ase AlreadySyncingError + if !errors.As(err, &ase) { + t.Fatalf("err = %v (%T), want AlreadySyncingError", err, err) + } +} + +func TestStartOtherErrorPassthrough(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":null,"errors":[{"message":"App not found"}]}`) + srv, c := stub.start(t) + defer srv.Close() + + err := Start(context.Background(), c, 42, 7) + if err == nil { + t.Fatal("expected error, got nil") + } + var ase AlreadySyncingError + if errors.As(err, &ase) { + t.Fatalf("expected non-AlreadySyncingError; got %v", err) + } + if !strings.Contains(err.Error(), "App not found") { + t.Fatalf("err = %v, want substring 'App not found'", err) + } +} + +func TestStatusReturnsProgress(t *testing.T) { + stub := newStub() + stub.setStatic("SyncProgress", `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":99,"steps":[ + {"name":"Backup","status":"success","step":"backup"}, + {"name":"Restore","status":"running","step":"restore"} + ]}} + ]}}}`) + srv, c := stub.start(t) + defer srv.Close() + + p, err := Status(context.Background(), c, 42, 7) + if err != nil { + t.Fatalf("Status: %v", err) + } + if p == nil { + t.Fatal("Status returned nil progress") + } + if p.Status != "running" || p.Sync != 99 { + t.Errorf("Progress = %+v, want status=running sync=99", p) + } + if len(p.Steps) != 2 { + t.Fatalf("Steps len = %d, want 2", len(p.Steps)) + } + if p.Steps[0].Step != "backup" || p.Steps[1].Status != "running" { + t.Errorf("Steps = %+v, want backup/success + restore/running", p.Steps) + } +} + +func TestPollTerminatesOnSuccess(t *testing.T) { + stub := newStub() + // First call: running. Second call: success. + stub.setSeq("SyncProgress", + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":1,"steps":[ + {"name":"Backup","status":"running","step":"backup"} + ]}} + ]}}}`, + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[ + {"name":"Backup","status":"success","step":"backup"} + ]}} + ]}}}`, + ) + srv, c := stub.start(t) + defer srv.Close() + + var transitions atomic.Int32 + p, err := Poll(context.Background(), c, 42, 7, PollOpts{ + Interval: 1 * time.Millisecond, + OnTransition: func(s Step) { + transitions.Add(1) + }, + }) + if err != nil { + t.Fatalf("Poll: %v", err) + } + if p == nil || p.Status != StatusSuccess { + t.Fatalf("Poll final = %+v, want status=success", p) + } + if n := transitions.Load(); n < 2 { + t.Errorf("transitions = %d, want >= 2 (running then success)", n) + } +} + +func TestPollRespectsCancel(t *testing.T) { + stub := newStub() + // Always running — Poll will never terminate on its own. + stub.setStatic("SyncProgress", `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":1,"steps":[]}} + ]}}}`) + srv, c := stub.start(t) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Poll(ctx, c, 42, 7, PollOpts{Interval: 5 * time.Millisecond}) + if err == nil { + t.Fatal("Poll should return ctx error on cancel, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want ctx error", err) + } +} + +func TestPollOnErrorTransient(t *testing.T) { + stub := newStub() + // First Status call returns an error; second returns success. + stub.setSeq("SyncProgress", + `{"data":null,"errors":[{"message":"transient blip"}]}`, + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[]}} + ]}}}`, + ) + srv, c := stub.start(t) + defer srv.Close() + + var sawErr atomic.Int32 + p, err := Poll(context.Background(), c, 42, 7, PollOpts{ + Interval: 1 * time.Millisecond, + OnError: func(e error) bool { + sawErr.Add(1) + return true // treat as transient + }, + }) + if err != nil { + t.Fatalf("Poll: %v", err) + } + if p == nil || p.Status != StatusSuccess { + t.Fatalf("Poll final = %+v, want status=success", p) + } + if sawErr.Load() == 0 { + t.Errorf("OnError never called; want at least once") + } +} diff --git a/internal/telemetry/config.go b/internal/telemetry/config.go new file mode 100644 index 000000000..870c726bb --- /dev/null +++ b/internal/telemetry/config.go @@ -0,0 +1,34 @@ +package telemetry + +import "strings" + +// Mirrors config/config.publish.json. +const ( + TracksEndpoint = "https://public-api.wordpress.com/rest/v1.1/tracks/record" + TracksUserType = "vip:user_id" + TracksAnonUserType = "anon" + TracksEventPrefix = "vip_cli_" + + // PendoEndpoint is the production Pendo endpoint — API_HOST's default + // (src/lib/api.ts:21, PRODUCTION_API_HOST) plus Pendo.ENDPOINT ("/pendo"). + // Use PendoEndpointFor to honour an overridden API_HOST. + PendoEndpoint = defaultAPIHost + pendoPath + PendoEventPrefix = TracksEventPrefix // same prefix as Tracks per tracker.ts + + defaultAPIHost = "https://api.wpvip.com" + pendoPath = "/pendo" +) + +// PendoEndpointFor builds the Pendo URL for an API host. +// +// Node sends Pendo events through src/lib/api/http.ts, which prefixes API_HOST +// (`process.env.API_HOST || PRODUCTION_API_HOST`), so pointing Node at staging +// points its analytics at staging. Go hardcoded the production URL, which meant +// a developer or CI job running against a local or staging API still emitted +// every event into the production analytics pipeline. +func PendoEndpointFor(apiHost string) string { + if apiHost == "" { + return PendoEndpoint + } + return strings.TrimSuffix(apiHost, "/") + pendoPath +} diff --git a/internal/telemetry/default.go b/internal/telemetry/default.go new file mode 100644 index 000000000..c36aadf75 --- /dev/null +++ b/internal/telemetry/default.go @@ -0,0 +1,67 @@ +package telemetry + +import ( + "os" + "sync" + + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/version" +) + +// NewDefault constructs a Tracker wired with Tracks + Pendo clients and the +// keychain-backed UUID store. Returns nil if construction fails — callers +// should check. +// +// When DO_NOT_TRACK / GO_ENV=test / NODE_ENV=test is set the tracker is +// returned in a pre-disabled state without touching the OS keychain, so +// test binaries never block on a Keychain Access prompt. +// +// The UUID lookup is deferred to first event emission via GetUserID so that +// construction never touches the OS keychain. This prevents Keychain Access +// prompts on every invocation (e.g. --version, --help). +func NewDefault() *Tracker { + if isDoNotTrack() { + return &Tracker{Disabled: true} + } + host := os.Getenv("API_HOST") + if host == "" { + host = "https://api.wpvip.com" + } + k := keychain.New(host) + uuidStore := &UUIDStore{Keychain: k} + + // Lazy UUID resolution — do NOT touch keychain at construction. + // First event emission will trigger the lookup (at most once). + var once sync.Once + var cachedUUID string + getUUID := func() string { + once.Do(func() { cachedUUID, _ = uuidStore.Get() }) + return cachedUUID + } + + // Follow the ldflags-injected build version (Makefile -X + // …/internal/version.Version). This was hardcoded to the literal + // "vip-next/dev", so every released build reported itself to Tracks and + // Pendo as a dev build and tagging a release silently changed nothing. + userAgent := "vip-next/" + version.Version + return &Tracker{ + Clients: []Client{ + &TracksClient{ + Endpoint: TracksEndpoint, + GetUserID: getUUID, + UserType: TracksAnonUserType, + UserAgent: userAgent, + }, + &PendoClient{ + // Node prefixes API_HOST (src/lib/api/http.ts); a staging or + // local run must not post into production analytics. + Endpoint: PendoEndpointFor(host), + GetUserID: getUUID, + UserAgent: userAgent, + EventPrefix: TracksEventPrefix, + }, + }, + UUIDStore: uuidStore, + Disabled: false, + } +} diff --git a/internal/telemetry/default_endpoint_test.go b/internal/telemetry/default_endpoint_test.go new file mode 100644 index 000000000..1bb2ebcc3 --- /dev/null +++ b/internal/telemetry/default_endpoint_test.go @@ -0,0 +1,111 @@ +package telemetry + +import ( + "strings" + "testing" + + "github.com/Automattic/vip/internal/version" +) + +// pendoEndpointOf digs the Pendo client out of a constructed Tracker. +func pendoEndpointOf(t *testing.T, tr *Tracker) string { + t.Helper() + if tr == nil { + t.Fatal("NewDefault returned nil") + } + for _, c := range tr.Clients { + if p, ok := c.(*PendoClient); ok { + return p.Endpoint + } + } + t.Fatal("no PendoClient in the default tracker") + return "" +} + +// TestPendoEndpointFollowsAPIHost is a privacy fix, not a cosmetic one. +// +// Node reaches Pendo through src/lib/api/http.ts, which prefixes +// `API_HOST` (src/lib/api.ts:21 — `process.env.API_HOST || PRODUCTION_API_HOST`). +// Point Node at staging and its analytics go to staging. +// +// Go hardcoded https://api.wpvip.com/pendo, so a developer or a CI job running +// against a local or staging API — the exact situation where you generate +// large volumes of junk events, and where the commands under test may be +// exercising a customer's data — still emitted every one of them to the +// PRODUCTION analytics pipeline. NewDefault already reads API_HOST for the +// keychain on the line above, which is what made the divergence easy to miss. +func TestPendoEndpointFollowsAPIHost(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com") + + got := pendoEndpointOf(t, NewDefault()) + + if strings.Contains(got, "api.wpvip.com") && !strings.Contains(got, "staging") { + t.Errorf("Pendo endpoint = %q; a staging run still ships telemetry to production", got) + } + if got != "https://api.staging.wpvip.com/pendo" { + t.Errorf("Pendo endpoint = %q, want https://api.staging.wpvip.com/pendo", got) + } +} + +// TestPendoEndpointDefaultsToProduction keeps the ordinary case unchanged. +func TestPendoEndpointDefaultsToProduction(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "") + + if got := pendoEndpointOf(t, NewDefault()); got != PendoEndpoint { + t.Errorf("Pendo endpoint = %q, want %q", got, PendoEndpoint) + } +} + +// TestPendoEndpointTolersatesATrailingSlash — API_HOST is user-supplied, and +// "https://api.wpvip.com/" would otherwise produce "…com//pendo". +func TestPendoEndpointTolersatesATrailingSlash(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com/") + + if got := pendoEndpointOf(t, NewDefault()); got != "https://api.staging.wpvip.com/pendo" { + t.Errorf("Pendo endpoint = %q, want https://api.staging.wpvip.com/pendo", got) + } +} + +// The user agent was hardcoded to the literal "vip-next/dev", so every release +// build reported itself as a dev build to Tracks and Pendo — tagging a release +// would silently not change it. It must follow the ldflags-injected version. +func TestDefaultTrackerUserAgentFollowsBuildVersion(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com") + + prev := version.Version + t.Cleanup(func() { version.Version = prev }) + version.Version = "5.0.0-beta" + + tr := NewDefault() + want := "vip-next/5.0.0-beta" + + var seen []string + for _, c := range tr.Clients { + switch client := c.(type) { + case *TracksClient: + seen = append(seen, client.UserAgent) + case *PendoClient: + seen = append(seen, client.UserAgent) + } + } + if len(seen) == 0 { + t.Fatal("no clients with a UserAgent; the assertion would be vacuous") + } + for _, got := range seen { + if got != want { + t.Errorf("UserAgent = %q, want %q", got, want) + } + } +} diff --git a/internal/telemetry/pendo.go b/internal/telemetry/pendo.go new file mode 100644 index 000000000..d4ffb94f6 --- /dev/null +++ b/internal/telemetry/pendo.go @@ -0,0 +1,154 @@ +package telemetry + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// PendoClient posts analytics events to Pendo via the VIP API proxy. +// +// Node parity: mirrors src/lib/analytics/clients/pendo.ts exactly. +// +// The Node client sends to Pendo.ENDPOINT = "/pendo" prefixed by API_HOST +// (https://api.wpvip.com), so the full URL is https://api.wpvip.com/pendo. +// Node attaches a bearer token via the shared http wrapper; Go telemetry +// calls are fire-and-forget without auth (same approach as TracksClient). +// +// Payload shape (mirrors Node's send() method): +// +// { +// "context": { ...env fields, org_id, org_slug, org_sfid, userAgent, userId }, +// "event": "<prefixed event name>", +// "properties": { ...eventProps }, +// "timestamp": <unix milliseconds>, +// "type": "track", +// "visitorId": "<userId>", +// "accountId": "<org_sfid>", +// } +type PendoClient struct { + // Endpoint is the full URL, e.g. PendoEndpoint. + Endpoint string + // UserID is the anonymous UUID identifying this visitor. + // If non-empty, used as-is. If empty and GetUserID is non-nil, GetUserID is called lazily. + UserID string + // GetUserID is called lazily on first TrackEvent when UserID is empty. + GetUserID func() string + // UserAgent is the CLI user-agent string. + UserAgent string + // EventPrefix is prepended to event names that don't already carry it. + EventPrefix string + // HTTP is the HTTP client; nil means a default 5-second-timeout client. + HTTP *http.Client +} + +// resolveUserID returns UserID if set, otherwise calls GetUserID(). +// Returns empty string when neither is configured. +func (c *PendoClient) resolveUserID() string { + if c.UserID != "" { + return c.UserID + } + if c.GetUserID != nil { + return c.GetUserID() + } + return "" +} + +// pendoContext mirrors the Node context object merged in trackEvent(). +// Fields use camelCase to match Node's JSON output exactly. +type pendoContext struct { + // Env-derived fields. + UserAgent string `json:"userAgent"` + // Identity fields set per event. + UserID string `json:"userId"` + OrgID any `json:"org_id"` + OrgSlug any `json:"org_slug"` + OrgSfid any `json:"org_sfid"` +} + +// pendoPayload is the JSON body sent to the Pendo endpoint. +// Field names match Node's body construction in send() exactly. +type pendoPayload struct { + Context pendoContext `json:"context"` + Event string `json:"event"` + Properties map[string]any `json:"properties"` + Timestamp int64 `json:"timestamp"` + Type string `json:"type"` + VisitorID string `json:"visitorId"` + AccountID string `json:"accountId"` +} + +// TrackEvent sends a single named event to Pendo. +// The event name is auto-prefixed with EventPrefix if not already present. +// Errors from the HTTP call are swallowed (Node returns false on error). +func (c *PendoClient) TrackEvent(name string, props map[string]any) error { + if !strings.HasPrefix(name, c.EventPrefix) { + name = c.EventPrefix + name + } + + if props == nil { + props = map[string]any{} + } + + userID := c.resolveUserID() + + // Build context — mirrors Node's trackEvent() context merge. + ctx := pendoContext{ + UserAgent: c.UserAgent, + UserID: userID, + OrgID: props["org_slug"], // Node sets org_id = eventProps.org_slug + OrgSlug: props["org_slug"], + OrgSfid: props["org_sfid"], + } + + // accountId = context.org_sfid (Node: `${ this.context.org_sfid as string }`) + accountID := "" + if v, ok := props["org_sfid"]; ok && v != nil { + if s, ok2 := v.(string); ok2 { + accountID = s + } + } + + payload := pendoPayload{ + Context: ctx, + Event: name, + Properties: props, + Timestamp: time.Now().UnixMilli(), + Type: "track", + VisitorID: userID, + AccountID: accountID, + } + + body, err := json.Marshal(payload) + if err != nil { + // Swallow, same as Node's catch block returning false. + return nil + } + + req, err := http.NewRequest("POST", c.Endpoint, bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + + httpClient := c.HTTP + if httpClient == nil { + // Node routes Pendo through api/http.ts (analytics/clients/pendo.ts:4), + // so it is proxied by createProxyAgent's policy — not by + // http.DefaultTransport's. See internal/httpproxy. + httpClient = httpproxy.ClientWithTimeout(5 * time.Second) + } + + resp, err := httpClient.Do(req) + if err != nil { + // Node: catch(error) { debug(error); return Promise.resolve(false) } + return nil + } + resp.Body.Close() + return nil +} diff --git a/internal/telemetry/pendo_test.go b/internal/telemetry/pendo_test.go new file mode 100644 index 000000000..7f7abb770 --- /dev/null +++ b/internal/telemetry/pendo_test.go @@ -0,0 +1,233 @@ +package telemetry + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// captureRequest runs a PendoClient.TrackEvent call against a local httptest +// server and returns the decoded request payload plus the raw HTTP request. +func captureRequest(t *testing.T, c *PendoClient, name string, props map[string]any) (pendoPayload, *http.Request) { + t.Helper() + var captured pendoPayload + var capturedReq *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + b, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(b, &captured); err != nil { + t.Fatalf("could not decode Pendo payload: %v\nbody: %s", err, b) + } + w.WriteHeader(200) + })) + t.Cleanup(srv.Close) + + c.Endpoint = srv.URL + if err := c.TrackEvent(name, props); err != nil { + t.Fatalf("TrackEvent returned unexpected error: %v", err) + } + if capturedReq == nil { + t.Fatal("no request received by test server") + } + return captured, capturedReq +} + +// TestPendoClientPostsExpectedPayload verifies that TrackEvent sends a POST +// with the correct JSON body matching Node's send() output: event name (prefixed), +// type "track", visitorId, accountId, context fields, and properties. +func TestPendoClientPostsExpectedPayload(t *testing.T) { + c := &PendoClient{ + UserID: "test-anon-uuid-1234", + UserAgent: "vip-cli/test-0.1", + EventPrefix: TracksEventPrefix, + } + props := map[string]any{ + "command": "vip whoami", + "org_slug": "my-org", + "org_sfid": "SF-999", + } + + payload, req := captureRequest(t, c, "whoami_command_execute", props) + + // --- HTTP method and Content-Type --- + if req.Method != "POST" { + t.Errorf("HTTP method = %q, want POST", req.Method) + } + ct := req.Header.Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if req.Header.Get("User-Agent") != "vip-cli/test-0.1" { + t.Errorf("User-Agent = %q, want vip-cli/test-0.1", req.Header.Get("User-Agent")) + } + + // --- type field --- + if payload.Type != "track" { + t.Errorf("type = %q, want track", payload.Type) + } + + // --- event name: should be auto-prefixed --- + wantEvent := "vip_cli_whoami_command_execute" + if payload.Event != wantEvent { + t.Errorf("event = %q, want %q", payload.Event, wantEvent) + } + + // --- visitorId == userId --- + if payload.VisitorID != "test-anon-uuid-1234" { + t.Errorf("visitorId = %q, want test-anon-uuid-1234", payload.VisitorID) + } + + // --- accountId == org_sfid --- + if payload.AccountID != "SF-999" { + t.Errorf("accountId = %q, want SF-999", payload.AccountID) + } + + // --- context.userId == userId --- + if payload.Context.UserID != "test-anon-uuid-1234" { + t.Errorf("context.userId = %q, want test-anon-uuid-1234", payload.Context.UserID) + } + + // --- context.userAgent --- + if payload.Context.UserAgent != "vip-cli/test-0.1" { + t.Errorf("context.userAgent = %q, want vip-cli/test-0.1", payload.Context.UserAgent) + } + + // --- properties are passed through --- + if v, ok := payload.Properties["command"]; !ok || v != "vip whoami" { + t.Errorf("properties[command] = %v, want vip whoami", v) + } + + // --- timestamp is non-zero --- + if payload.Timestamp == 0 { + t.Errorf("timestamp = 0, want non-zero Unix milliseconds") + } +} + +// TestPendoClientCarriesUserIdentity verifies that the anonymous UUID appears +// in both visitorId (top-level) and context.userId, matching Node's behavior +// where visitorId = `${ this.context.userId }` and context.userId = this.userId. +func TestPendoClientCarriesUserIdentity(t *testing.T) { + const anonID = "deadbeef-cafe-babe-0000-111122223333" + c := &PendoClient{ + UserID: anonID, + UserAgent: "vip-cli/test-0.1", + EventPrefix: TracksEventPrefix, + } + + payload, _ := captureRequest(t, c, "some_event", nil) + + if payload.VisitorID != anonID { + t.Errorf("visitorId = %q, want %q", payload.VisitorID, anonID) + } + if payload.Context.UserID != anonID { + t.Errorf("context.userId = %q, want %q", payload.Context.UserID, anonID) + } +} + +// TestPendoClientHonorsExplicitPrefix verifies that a name already carrying +// the prefix is not double-prefixed (mirrors Node: if (!eventName.startsWith(this.eventPrefix))). +func TestPendoClientHonorsExplicitPrefix(t *testing.T) { + c := &PendoClient{ + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + + payload, _ := captureRequest(t, c, "vip_cli_already_prefixed", nil) + + if payload.Event != "vip_cli_already_prefixed" { + t.Errorf("event = %q (must not double-prefix)", payload.Event) + } +} + +// TestPendoClientOrgContextFields verifies that org_id, org_slug, and org_sfid +// from eventProps are copied into the context (Node: this.context.org_id = eventProps.org_slug). +func TestPendoClientOrgContextFields(t *testing.T) { + c := &PendoClient{ + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + props := map[string]any{ + "org_slug": "acme", + "org_sfid": "SF-001", + } + + payload, _ := captureRequest(t, c, "test_event", props) + + // org_id and org_slug should both equal eventProps.org_slug (Node behavior). + if payload.Context.OrgID != "acme" { + t.Errorf("context.org_id = %v, want acme", payload.Context.OrgID) + } + if payload.Context.OrgSlug != "acme" { + t.Errorf("context.org_slug = %v, want acme", payload.Context.OrgSlug) + } + if payload.Context.OrgSfid != "SF-001" { + t.Errorf("context.org_sfid = %v, want SF-001", payload.Context.OrgSfid) + } + if payload.AccountID != "SF-001" { + t.Errorf("accountId = %q, want SF-001", payload.AccountID) + } +} + +// TestPendoClientSwallowsNetworkError verifies that a network failure returns +// nil (not an error), mirroring Node's catch block returning Promise.resolve(false). +func TestPendoClientSwallowsNetworkError(t *testing.T) { + c := &PendoClient{ + Endpoint: "http://127.0.0.1:1", // nothing listening + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + if err := c.TrackEvent("test", nil); err != nil { + t.Errorf("expected nil on network error (Node swallows errors), got %v", err) + } +} + +// TestPendoClientLazyUserIDResolved verifies that GetUserID is not called at +// construction time — only at TrackEvent call time. +func TestPendoClientLazyUserIDResolved(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + c := &PendoClient{ + Endpoint: srv.URL, + GetUserID: func() string { calls++; return "lazy-uuid" }, + UserAgent: "vip-next/test", + EventPrefix: TracksEventPrefix, + } + // Before TrackEvent: GetUserID must not be called. + if calls != 0 { + t.Errorf("GetUserID called %d times before TrackEvent; want 0", calls) + } + if err := c.TrackEvent("test", nil); err != nil { + t.Fatalf("TrackEvent: %v", err) + } + if calls != 1 { + t.Errorf("GetUserID called %d times after TrackEvent; want 1", calls) + } +} + +// TestPendoClientPrefersExplicitUserID verifies that GetUserID is never called +// when UserID is already set explicitly. +func TestPendoClientPrefersExplicitUserID(t *testing.T) { + c := &PendoClient{ + UserID: "explicit", + GetUserID: func() string { t.Error("GetUserID must not be called when UserID is set"); return "" }, + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + payload, _ := captureRequest(t, c, "test_event", nil) + if payload.VisitorID != "explicit" { + t.Errorf("visitorId = %q, want explicit", payload.VisitorID) + } + if payload.Context.UserID != "explicit" { + t.Errorf("context.userId = %q, want explicit", payload.Context.UserID) + } +} diff --git a/internal/telemetry/scrub.go b/internal/telemetry/scrub.go new file mode 100644 index 000000000..5e18bb56c --- /dev/null +++ b/internal/telemetry/scrub.go @@ -0,0 +1,90 @@ +package telemetry + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/Automattic/vip/internal/redact" +) + +// userPathRE matches the home-directory roots whose next path segment is a +// username. Anchored on the platform conventions rather than on "any absolute +// path", because /usr/local/bin and /etc/hosts are not sensitive and stripping +// them would gut the payload. +var userPathRE = regexp.MustCompile(`(?i)(/Users/|/home/|[A-Z]:\\Users\\)([^/\\:;,'"` + "`" + `\s]+)`) + +// ScrubErrorText removes personally identifying and credential material from +// the text of an error before it is attached to a telemetry event. +// +// This exists because cmd/vip-next/main.go registers a cli_error hook that +// posts err.Error() to public-api.wordpress.com. That hook is Go-only — the +// Node CLI has no equivalent and never sends error text anywhere — so every +// byte it carries is surface the rewrite added. vip-next errors routinely +// interpolate absolute paths (import sql, import media, dev-env, and every +// wrapped os.Open failure), and an absolute path carries the account name, +// which is often the user's real name, and the directory tree, which is often a +// client's name. +// +// Removed, in order: +// +// 1. credentials, via internal/redact — presigned query strings, URL userinfo, +// JWTs, Bearer tokens; +// 2. this process's working directory, temp directory and home directory, +// longest match first so a cwd nested inside home does not decay to +// "$HOME/clients/acme-corp"; +// 3. any remaining /Users/<name>, /home/<name> or C:\Users\<name>, which is +// the net for paths that came from a config file, instance data or a server +// response rather than from this process. +// +// Kept: everything else. A scrubbed message still names the operation, the +// filename, the host and the failure, which is the whole justification for +// scrubbing rather than dropping the hook. +func ScrubErrorText(s string) string { + s = redact.Text(s) + for _, r := range scrubRoots() { + s = strings.ReplaceAll(s, r.path, r.placeholder) + } + return userPathRE.ReplaceAllString(s, "$1<redacted-user>") +} + +type scrubRoot struct { + path string + placeholder string +} + +// scrubRoots returns the directories to anonymise, longest first. +// +// Each root is offered in both its literal and symlink-resolved form: on macOS +// os.TempDir() reports /var/folders/... while anything that actually opened a +// file there reports /private/var/folders/..., and the two must both go. +func scrubRoots() []scrubRoot { + var roots []scrubRoot + add := func(path, placeholder string) { + path = strings.TrimSuffix(filepath.Clean(path), string(filepath.Separator)) + if path == "" || path == string(filepath.Separator) { + return + } + roots = append(roots, scrubRoot{path: path, placeholder: placeholder}) + if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path { + roots = append(roots, scrubRoot{path: resolved, placeholder: placeholder}) + } + } + + if cwd, err := os.Getwd(); err == nil { + add(cwd, "<cwd>") + } + add(os.TempDir(), "<tmp>") + if home, err := os.UserHomeDir(); err == nil { + add(home, "<home>") + } + + // Longest first: the working directory is usually inside the home + // directory, and replacing home first would leave the project path visible. + sort.SliceStable(roots, func(i, j int) bool { + return len(roots[i].path) > len(roots[j].path) + }) + return roots +} diff --git a/internal/telemetry/scrub_test.go b/internal/telemetry/scrub_test.go new file mode 100644 index 000000000..e4cffc49b --- /dev/null +++ b/internal/telemetry/scrub_test.go @@ -0,0 +1,131 @@ +package telemetry + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestScrubErrorTextRemovesTheHomeDirectory is the finding that motivated this +// function: the cli_error hook ships err.Error() verbatim to +// public-api.wordpress.com, and vip-next errors interpolate absolute paths +// constantly (import sql, import media, dev-env, every os.Open failure). The +// home directory contains the user's account name, which is PII on its own and +// frequently their real name. +func TestScrubErrorTextRemovesTheHomeDirectory(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + path := filepath.Join(home, "clients", "acme-corp", "db-dump.sql") + + got := ScrubErrorText("open " + path + ": permission denied") + + if strings.Contains(got, home) { + t.Errorf("home directory survived:\n\t%s", got) + } + if !strings.Contains(got, "permission denied") { + t.Errorf("the actual failure was lost:\n\t%s", got) + } +} + +// TestScrubErrorTextRemovesTheWorkingDirectory pins the ordering. The working +// directory is usually INSIDE the home directory, so a scrubber that replaced +// home first would emit "$HOME/clients/acme-corp/...", still naming the client. +// Longest prefix must win. +func TestScrubErrorTextRemovesTheWorkingDirectory(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Skipf("no working directory available: %v", err) + } + + got := ScrubErrorText("could not read " + filepath.Join(cwd, "wp-config.php")) + + if strings.Contains(got, cwd) { + t.Errorf("working directory survived:\n\t%s", got) + } + if !strings.Contains(got, "wp-config.php") { + t.Errorf("the filename was lost; it is the diagnostic part:\n\t%s", got) + } +} + +func TestScrubErrorTextRemovesTheTempDirectory(t *testing.T) { + tmp := os.TempDir() + + got := ScrubErrorText("staging file " + filepath.Join(tmp, "vip-import-9271", "chunk.0") + " vanished") + + if strings.Contains(got, tmp) { + t.Errorf("temp directory survived:\n\t%s", got) + } +} + +// TestScrubErrorTextRemovesForeignHomePaths is the safety net. Not every +// absolute path in an error came from THIS process's home: paths are read out +// of config files, instance data, SQL dumps and server responses. The username +// is the sensitive part, so it goes regardless of which root it hangs off. +func TestScrubErrorTextRemovesForeignHomePaths(t *testing.T) { + cases := []string{ + "/Users/jsmith/Sites/client/wp-content", + "/home/jsmith/sites/client/wp-content", + } + if runtime.GOOS == "windows" { + cases = append(cases, `C:\Users\jsmith\Sites\client`) + } + for _, path := range cases { + got := ScrubErrorText("no such file: " + path) + if strings.Contains(got, "jsmith") { + t.Errorf("username survived in %q:\n\t%s", path, got) + } + if !strings.Contains(got, "no such file") { + t.Errorf("message body lost for %q:\n\t%s", path, got) + } + } +} + +// TestScrubErrorTextAlsoRemovesCredentials confirms the path scrubbing is +// layered on top of internal/redact rather than replacing it. An earlier slice +// found proxy errors carrying socks5://user:pass@host into this exact hook and +// redacted them at the source; this is the second line of defence, for the +// sources nobody has audited yet. +func TestScrubErrorTextAlsoRemovesCredentials(t *testing.T) { + got := ScrubErrorText(`Get "https://vip.s3.amazonaws.com/export.sql?X-Amz-Signature=abc123def456": timeout`) + if strings.Contains(got, "X-Amz-Signature") || strings.Contains(got, "abc123def456") { + t.Errorf("presigned credential survived:\n\t%s", got) + } + + got = ScrubErrorText("proxy socks5://alice:hunter2@corp.example:1080 refused") + if strings.Contains(got, "hunter2") { + t.Errorf("proxy password survived:\n\t%s", got) + } +} + +// TestScrubErrorTextKeepsOrdinaryMessagesIntact is the counterweight: the whole +// point of scrubbing rather than dropping the hook is that the payload stays +// useful. If this test starts failing, the scrubber has become too aggressive +// and removing the hook (Node has none) is the better trade. +func TestScrubErrorTextKeepsOrdinaryMessagesIntact(t *testing.T) { + for _, msg := range []string{ + "appctx: GraphQL client not configured", + "environment my-site is not running; run `vip dev-env start`", + "failed to reach public-api.wordpress.com: connection refused", + "import sql: file is not a valid SQL export", + } { + if got := ScrubErrorText(msg); got != msg { + t.Errorf("scrubber rewrote a message with nothing sensitive in it:\n\tin: %s\n\tout: %s", msg, got) + } + } +} + +func TestScrubErrorTextIsIdempotent(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + in := "open " + filepath.Join(home, "a", "b.sql") + ": denied" + once := ScrubErrorText(in) + if twice := ScrubErrorText(once); twice != once { + t.Errorf("not idempotent:\n\t1x: %s\n\t2x: %s", once, twice) + } +} diff --git a/internal/telemetry/tracker.go b/internal/telemetry/tracker.go new file mode 100644 index 000000000..fefccde74 --- /dev/null +++ b/internal/telemetry/tracker.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "fmt" + "os" +) + +// Client is the common interface satisfied by TracksClient and PendoClient. +type Client interface { + TrackEvent(name string, props map[string]any) error +} + +// Tracker fans out analytics events to all configured Clients. +// Set Disabled to suppress all events without removing the clients. +// isDoNotTrack() also suppresses events when the environment signals opt-out. +type Tracker struct { + Clients []Client + UUIDStore *UUIDStore + Disabled bool +} + +// TrackEvent emits name with props to every configured Client. +// It is a no-op when the tracker is disabled or DO_NOT_TRACK / test env vars are set. +func (t *Tracker) TrackEvent(name string, props map[string]any) { + if t.Disabled || isDoNotTrack() { + return + } + for _, c := range t.Clients { + _ = c.TrackEvent(name, props) + } +} + +// AliasUser emits a special "_alias_user" event that links the anonymous UUID +// to the authenticated VIP user ID, then updates the UUID store so subsequent +// events carry the user's real identity — mirroring aliasUser() in tracker.ts. +func (t *Tracker) AliasUser(vipUserID int64) { + if vipUserID == 0 || t.Disabled || isDoNotTrack() { + return + } + prevID := "" + if t.UUIDStore != nil { + prevID, _ = t.UUIDStore.Get() + } + t.TrackEvent("_alias_user", map[string]any{ + "_ui": vipUserID, + "_ut": TracksUserType, + "anonid": prevID, + }) + if t.UUIDStore != nil { + _ = t.UUIDStore.Set(fmt.Sprintf("%d", vipUserID)) + } +} + +// MakeCommandTracker returns a closure that emits "<command>_command_<eventType>" +// events, merging baseInfo with any per-call data — mirroring makeCommandTracker() +// in tracker.ts. +func (t *Tracker) MakeCommandTracker(command string, info map[string]any) func(string, map[string]any) { + return func(eventType string, data map[string]any) { + merged := make(map[string]any, len(info)+len(data)) + for k, v := range info { + merged[k] = v + } + for k, v := range data { + merged[k] = v + } + t.TrackEvent(fmt.Sprintf("%s_command_%s", command, eventType), merged) + } +} + +// isDoNotTrack returns true when any of the standard opt-out environment +// variables are set, matching the Node binary's behaviour. +func isDoNotTrack() bool { + return os.Getenv("DO_NOT_TRACK") != "" || + os.Getenv("GO_ENV") == "test" || + os.Getenv("NODE_ENV") == "test" +} diff --git a/internal/telemetry/tracker_test.go b/internal/telemetry/tracker_test.go new file mode 100644 index 000000000..3b380c414 --- /dev/null +++ b/internal/telemetry/tracker_test.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "sync" + "testing" +) + +type fakeClient struct { + mu sync.Mutex + events []string + props []map[string]any +} + +func (f *fakeClient) TrackEvent(name string, props map[string]any) error { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, name) + f.props = append(f.props, props) + return nil +} + +func TestTrackerFanOut(t *testing.T) { + a, b := &fakeClient{}, &fakeClient{} + tr := &Tracker{Clients: []Client{a, b}} + tr.TrackEvent("foo", map[string]any{"x": 1}) + if len(a.events) != 1 || len(b.events) != 1 { + t.Errorf("expected fan-out; a=%d b=%d", len(a.events), len(b.events)) + } +} + +func TestTrackerDoNotTrackDisables(t *testing.T) { + c := &fakeClient{} + tr := &Tracker{Clients: []Client{c}, Disabled: true} + tr.TrackEvent("foo", nil) + if len(c.events) != 0 { + t.Error("expected no events when Disabled") + } +} + +func TestMakeCommandTracker(t *testing.T) { + c := &fakeClient{} + tr := &Tracker{Clients: []Client{c}} + ct := tr.MakeCommandTracker("whoami", map[string]any{"command": "vip whoami"}) + ct("execute", nil) + ct("success", map[string]any{"duration_ms": 42}) + if len(c.events) != 2 { + t.Fatalf("expected 2 events, got %d", len(c.events)) + } + if c.events[0] != "whoami_command_execute" || c.events[1] != "whoami_command_success" { + t.Errorf("event names = %v", c.events) + } + if c.props[1]["command"] != "vip whoami" || c.props[1]["duration_ms"] != 42 { + t.Errorf("merged props = %v", c.props[1]) + } +} + +func TestAliasUserEmitsAliasEvent(t *testing.T) { + c := &fakeClient{} + store := newTestUUIDStore() + store.Set("anon-id") + tr := &Tracker{Clients: []Client{c}, UUIDStore: store} + tr.AliasUser(99) + if len(c.events) != 1 || c.events[0] != "_alias_user" { + t.Errorf("expected _alias_user event, got %v", c.events) + } + if c.props[0]["_ui"] != int64(99) { + t.Errorf("_ui = %v, want 99", c.props[0]["_ui"]) + } + if c.props[0]["anonid"] != "anon-id" { + t.Errorf("anonid = %v, want anon-id", c.props[0]["anonid"]) + } + got, _ := store.Get() + if got != "99" { + t.Errorf("UUID after alias = %q, want %q", got, "99") + } +} diff --git a/internal/telemetry/tracks.go b/internal/telemetry/tracks.go new file mode 100644 index 000000000..efa0555ad --- /dev/null +++ b/internal/telemetry/tracks.go @@ -0,0 +1,82 @@ +package telemetry + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// TracksClient posts analytics events to Automattic Tracks. +// +// Node parity: field names and prefix logic match src/lib/analytics/clients/tracks.ts. +// +// Known gap: Node's trackEvent sets `is_vip` on every event via checkIfUserIsVip(), +// which performs a per-event GraphQL call. That per-event network call is too expensive +// to replicate here; is_vip is intentionally omitted until a cached approach is designed. +// +// Addition vs Node: events[0][cli_binary_kind]=go-native is injected on every event +// per spec §9.3 for rollout adoption tracking. +type TracksClient struct { + Endpoint string + UserID string // if non-empty, used as-is + GetUserID func() string // called lazily on first TrackEvent when UserID is empty + UserType string + UserAgent string + HTTP *http.Client +} + +// resolveUserID returns UserID if set, otherwise calls GetUserID(). +// Returns empty string when neither is configured. +func (c *TracksClient) resolveUserID() string { + if c.UserID != "" { + return c.UserID + } + if c.GetUserID != nil { + return c.GetUserID() + } + return "" +} + +// TrackEvent sends a single named event to Tracks. +// The event name is auto-prefixed with TracksEventPrefix ("vip_cli_") if not already present. +func (c *TracksClient) TrackEvent(name string, props map[string]any) error { + if !strings.HasPrefix(name, TracksEventPrefix) { + name = TracksEventPrefix + name + } + + form := url.Values{} + form.Set("commonProps[_ui]", c.resolveUserID()) + form.Set("commonProps[_ut]", c.UserType) + form.Set("commonProps[_via_ua]", c.UserAgent) + form.Set("events[0][_en]", name) + // Spec §9.3: rollout adoption tracking — not present in Node binary. + form.Set("events[0][cli_binary_kind]", "go-native") + for k, v := range props { + form.Set(fmt.Sprintf("events[0][%s]", k), fmt.Sprint(v)) + } + + req, err := http.NewRequest("POST", c.Endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.UserAgent) + + httpClient := c.HTTP + if httpClient == nil { + // A bare &http.Client{} inherits http.DefaultTransport's proxy policy, + // which is the inverse of Node's. See internal/httpproxy. + httpClient = httpproxy.ClientWithTimeout(5 * time.Second) + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/telemetry/tracks_test.go b/internal/telemetry/tracks_test.go new file mode 100644 index 000000000..efc37fe6e --- /dev/null +++ b/internal/telemetry/tracks_test.go @@ -0,0 +1,129 @@ +package telemetry + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestTracksClientPostsExpectedForm(t *testing.T) { + var body string + var ua string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.Header.Get("User-Agent") + b, _ := io.ReadAll(r.Body) + body = string(b) + w.WriteHeader(200) + })) + defer srv.Close() + c := &TracksClient{ + Endpoint: srv.URL, + UserID: "anon-uuid", + UserType: TracksAnonUserType, + UserAgent: "vip-next/test1.0", + } + if err := c.TrackEvent("whoami_command_execute", map[string]any{"command": "vip whoami"}); err != nil { + t.Fatalf("TrackEvent: %v", err) + } + if ua != "vip-next/test1.0" { + t.Errorf("User-Agent = %q, want vip-next/test1.0", ua) + } + v, err := url.ParseQuery(body) + if err != nil { + t.Fatalf("body not form-encoded: %v", err) + } + if v.Get("events[0][_en]") != "vip_cli_whoami_command_execute" { + t.Errorf("event name = %q, want vip_cli_whoami_command_execute", v.Get("events[0][_en]")) + } + if v.Get("events[0][command]") != "vip whoami" { + t.Errorf("event prop = %q", v.Get("events[0][command]")) + } + if v.Get("commonProps[_ui]") != "anon-uuid" { + t.Errorf("commonProps[_ui] = %q", v.Get("commonProps[_ui]")) + } + if v.Get("commonProps[_ut]") != "anon" { + t.Errorf("commonProps[_ut] = %q", v.Get("commonProps[_ut]")) + } +} + +func TestTracksClientHonorsExplicitPrefix(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + })) + defer srv.Close() + c := &TracksClient{Endpoint: srv.URL, UserID: "u", UserType: "anon", UserAgent: "x"} + c.TrackEvent("vip_cli_already_prefixed", nil) + v, _ := url.ParseQuery(body) + if v.Get("events[0][_en]") != "vip_cli_already_prefixed" { + t.Errorf("event name = %q (must not double-prefix)", v.Get("events[0][_en]")) + } +} + +func TestTracksClientSendsBinaryKind(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + })) + defer srv.Close() + c := &TracksClient{Endpoint: srv.URL, UserID: "u", UserType: "anon", UserAgent: "x"} + c.TrackEvent("test", nil) + v, _ := url.ParseQuery(body) + if v.Get("events[0][cli_binary_kind]") != "go-native" { + t.Errorf("expected cli_binary_kind=go-native; body=%s", body) + } + if !strings.Contains(body, "cli_binary_kind") { + t.Errorf("body missing cli_binary_kind: %s", body) + } +} + +func TestTracksClientLazyUserIDResolved(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + c := &TracksClient{ + Endpoint: srv.URL, + GetUserID: func() string { calls++; return "lazy-uuid" }, + UserType: "anon", + UserAgent: "vip-next/test", + } + // Before TrackEvent: GetUserID must not be called. + if calls != 0 { + t.Errorf("GetUserID called %d times before TrackEvent; want 0", calls) + } + c.TrackEvent("test", nil) + if calls != 1 { + t.Errorf("GetUserID called %d times after TrackEvent; want 1", calls) + } +} + +func TestTracksClientPrefersExplicitUserID(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + w.WriteHeader(200) + })) + defer srv.Close() + + c := &TracksClient{ + Endpoint: srv.URL, + UserID: "explicit", + GetUserID: func() string { t.Error("GetUserID must not be called when UserID is set"); return "" }, + UserType: "anon", + UserAgent: "x", + } + c.TrackEvent("test", nil) + v, _ := url.ParseQuery(body) + if v.Get("commonProps[_ui]") != "explicit" { + t.Errorf("commonProps[_ui] = %q, want explicit", v.Get("commonProps[_ui]")) + } +} diff --git a/internal/telemetry/uuid.go b/internal/telemetry/uuid.go new file mode 100644 index 000000000..a11954f45 --- /dev/null +++ b/internal/telemetry/uuid.go @@ -0,0 +1,50 @@ +// Package telemetry handles analytics (Tracks + Pendo) for the Go binary. +// Anonymous UUIDs use vip-next's private keychain namespace so writes cannot +// alter the Node CLI's telemetry identity. +package telemetry + +import ( + "crypto/rand" + "encoding/hex" + "errors" + + "github.com/Automattic/vip/internal/keychain" +) + +type UUIDStore struct { + Keychain *keychain.Keychain +} + +func (s *UUIDStore) serviceName() string { return s.Keychain.Service + "-uuid" } + +func (s *UUIDStore) Get() (string, error) { + svc := s.serviceName() + v, err := s.Keychain.Backend.Get(svc, svc) + if err == nil { + return v, nil + } + if !errors.Is(err, keychain.ErrNotFound) { + return "", err + } + id, err := newRandomUUID() + if err != nil { + return "", err + } + if err := s.Keychain.Backend.Set(svc, svc, id); err != nil { + return "", err + } + return id, nil +} + +func (s *UUIDStore) Set(id string) error { + svc := s.serviceName() + return s.Keychain.Backend.Set(svc, svc, id) +} + +func newRandomUUID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/telemetry/uuid_test.go b/internal/telemetry/uuid_test.go new file mode 100644 index 000000000..9531da836 --- /dev/null +++ b/internal/telemetry/uuid_test.go @@ -0,0 +1,90 @@ +package telemetry + +import ( + "errors" + "testing" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + delete(m.store, s+"|"+u) + return nil +} + +func newTestUUIDStore() *UUIDStore { + return &UUIDStore{ + Keychain: &keychain.Keychain{Backend: &memBackend{}, Service: "vip-next-cli"}, + } +} + +func TestGetUUIDGeneratesAndPersistsWhenMissing(t *testing.T) { + s := newTestUUIDStore() + id1, err := s.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if id1 == "" { + t.Error("generated UUID is empty") + } + id2, _ := s.Get() + if id1 != id2 { + t.Errorf("second Get returned different UUID: %q vs %q", id1, id2) + } +} + +func TestSetUUIDPersistsExplicitValue(t *testing.T) { + s := newTestUUIDStore() + if err := s.Set("explicit-id-42"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := s.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "explicit-id-42" { + t.Errorf("Get = %q, want %q", got, "explicit-id-42") + } +} + +func TestUUIDStoreUsesUUIDServiceSuffix(t *testing.T) { + s := newTestUUIDStore() + s.Set("test-id") + be := s.Keychain.Backend.(*memBackend) + if _, ok := be.store["vip-next-cli-uuid|vip-next-cli-uuid"]; !ok { + t.Errorf("expected key vip-next-cli-uuid|vip-next-cli-uuid in store; got %v", be.store) + } +} + +func TestUUIDStoreReturnsErrOnBackendError(t *testing.T) { + be := &errBackend{} + s := &UUIDStore{Keychain: &keychain.Keychain{Backend: be, Service: "vip-next-cli"}} + _, err := s.Get() + if err == nil { + t.Error("expected error when backend fails") + } + if errors.Is(err, keychain.ErrNotFound) { + t.Error("non-NotFound errors must surface as-is") + } +} + +type errBackend struct{} + +func (errBackend) Set(string, string, string) error { return errors.New("boom") } +func (errBackend) Get(string, string) (string, error) { return "", errors.New("boom") } +func (errBackend) Delete(string, string) error { return errors.New("boom") } diff --git a/internal/tui/progress.go b/internal/tui/progress.go new file mode 100644 index 000000000..318eee74c --- /dev/null +++ b/internal/tui/progress.go @@ -0,0 +1,153 @@ +// Package tui hosts terminal UI primitives shared across commands. +// +// MultiLineRenderer drives in-place spinner/step-list rendering (vip +// sync's progress display today; future heavy commands such as backup +// progress and SQL-import progress will share it). Tested in isolation +// so the per-command callers stay free of ANSI string-building. +// +// Scope is intentionally narrow: this package hosts UI primitives, not a +// widget library. ProgressTracker (progress_tracker.go) is the shared +// step-list/spinner port of Node's lib/cli/progress.ts used by the heavy +// commands; truly command-specific framing still lives with callers in +// cmd/. +package tui + +import ( + "fmt" + "io" + "regexp" + + "golang.org/x/term" +) + +// ansiSGRRe matches CSI escape sequences (colors etc.) so visibleWidth can +// measure the on-screen width of a colorized line. +var ansiSGRRe = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]") + +// visibleWidth is the number of on-screen columns a line occupies: its rune +// count with ANSI escape sequences stripped (the step glyphs are color-wrapped, +// e.g. a green ✓, which must not inflate the width). +func visibleWidth(s string) int { + return len([]rune(ansiSGRRe.ReplaceAllString(s, ""))) +} + +// MultiLineRenderer rewrites a multi-line block in place when attached +// to a TTY, falling back to plain append on non-TTY writers. +// +// Concurrency: not safe for concurrent use; callers must serialize +// Render/Done. +type MultiLineRenderer struct { + w io.Writer + tty bool + // fd is the terminal file descriptor used to query the width, or -1 when + // the writer is not a terminal file (e.g. a bytes.Buffer in tests). + fd int + // width, when > 0, overrides the queried terminal width (test seam). + width int + // lastRows is the number of PHYSICAL rows the previous frame occupied — + // long lines wrap, so this is not the same as the logical line count. + lastRows int +} + +// NewMultiLineRenderer constructs a renderer. When tty is false the +// renderer never emits ANSI escape sequences and Render simply appends +// each frame's lines to w (CI / pipe behavior). On a TTY, if w exposes a +// terminal file descriptor the renderer becomes width-aware so wrapped +// lines are cleared correctly. +func NewMultiLineRenderer(w io.Writer, tty bool) *MultiLineRenderer { + r := &MultiLineRenderer{w: w, tty: tty, fd: -1} + if tty { + if f, ok := w.(interface{ Fd() uintptr }); ok { + r.fd = int(f.Fd()) + } + } + return r +} + +// cols returns the current terminal width, or 0 when it can't be determined +// (in which case rendering falls back to counting logical lines). +func (r *MultiLineRenderer) cols() int { + if r.width > 0 { + return r.width + } + if r.fd >= 0 { + if c, _, err := term.GetSize(r.fd); err == nil && c > 0 { + return c + } + } + return 0 +} + +// physicalRows is the number of screen rows a frame occupies once long lines +// wrap at the terminal width. When the width is unknown it degrades to the +// logical line count (the pre-width-aware behavior, fine for non-wrapping +// callers and buffer-backed tests). +func (r *MultiLineRenderer) physicalRows(lines []string) int { + c := r.cols() + if !r.tty || c <= 0 { + return len(lines) + } + rows := 0 + for _, line := range lines { + w := visibleWidth(line) + if w == 0 { + rows++ // an empty line still occupies one row + } else { + rows += (w + c - 1) / c // ceil(w / cols) + } + } + return rows +} + +// Render writes a frame. On TTY, subsequent calls overwrite the +// previously rendered block by moving the cursor up and erasing each +// prior PHYSICAL row before re-emitting. On non-TTY, every call appends. +// +// The frame is always terminated with newlines so the cursor lands on a +// fresh line, which keeps the math simple for the next call (we know the +// cursor is lastRows below the frame's first row). Because a line longer +// than the terminal wraps onto multiple rows, the cursor movement counts +// physical rows, not logical lines — counting lines leaves the wrapped +// remainder on screen, which is the "repeated lines" progress bug. +func (r *MultiLineRenderer) Render(lines []string) { + if r.tty && r.lastRows > 0 { + // Move cursor up to the first row of the previous frame. + // \033[<n>F moves up n rows and parks at column 1. + fmt.Fprintf(r.w, "\033[%dF", r.lastRows) + // Erase each previous row. \033[2K clears the entire line; + // \033[1B moves down one line without scrolling. We deliberately + // don't combine these into a single "clear-from-cursor-to-end" + // (\033[J) because that also nukes anything below — and on some + // terminals (notably tmux) it can leave artifacts when the new + // frame is shorter than the old one. + for i := 0; i < r.lastRows; i++ { + fmt.Fprint(r.w, "\033[2K") + if i < r.lastRows-1 { + fmt.Fprint(r.w, "\033[1B") + } + } + // Cursor is now on the last cleared row. Move back up to the + // first cleared row so the upcoming Fprintln calls overwrite + // from the top. lastRows-1 because we're already on the last + // of the n cleared rows. + if r.lastRows > 1 { + fmt.Fprintf(r.w, "\033[%dF", r.lastRows-1) + } else { + // Single-row case: we're sitting on the cleared row at + // column 1, ready to write — no further movement needed. + fmt.Fprint(r.w, "\r") + } + } + for _, line := range lines { + fmt.Fprintln(r.w, line) + } + r.lastRows = r.physicalRows(lines) +} + +// Done resets internal state so the next Render writes a fresh frame +// rather than trying to overwrite the (now-finalized) previous one. +// Callers invoke this after they've printed terminal-state output and +// want subsequent writes to flow naturally. +func (r *MultiLineRenderer) Done() { + r.lastRows = 0 +} diff --git a/internal/tui/progress_test.go b/internal/tui/progress_test.go new file mode 100644 index 000000000..45266c58a --- /dev/null +++ b/internal/tui/progress_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "bytes" + "strings" + "testing" +) + +func TestMultiLineRendererFirstFrame(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true /*tty*/) + r.Render([]string{"step1", "step2", "step3"}) + out := buf.String() + if !strings.Contains(out, "step1") || !strings.Contains(out, "step2") || !strings.Contains(out, "step3") { + t.Errorf("first frame must write all lines; got %q", out) + } + // No cursor-up sequence on first frame. + if strings.Contains(out, "\033[3F") || strings.Contains(out, "\033[3A") { + t.Errorf("first frame must not emit cursor-up; got %q", out) + } +} + +func TestMultiLineRendererSubsequentFrameRedraws(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true) + r.Render([]string{"a", "b"}) + buf.Reset() + r.Render([]string{"a'", "b'"}) + out := buf.String() + if !strings.Contains(out, "\033[") { + t.Errorf("second frame must emit ANSI cursor manipulation; got %q", out) + } + if !strings.Contains(out, "a'") || !strings.Contains(out, "b'") { + t.Errorf("second frame must include new lines; got %q", out) + } +} + +func TestMultiLineRendererNonTTYWritesLinesNoANSI(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, false /*non-tty*/) + r.Render([]string{"a", "b"}) + r.Render([]string{"c", "d"}) + out := buf.String() + if strings.Contains(out, "\033[") { + t.Errorf("non-TTY must emit zero ANSI escapes; got %q", out) + } + for _, want := range []string{"a", "b", "c", "d"} { + if !strings.Contains(out, want) { + t.Errorf("non-TTY output missing %q; got %q", want, out) + } + } +} + +// TestMultiLineRendererWidthAwareCursorUp is the regression for the sync/import +// progress "repeated lines" bug: a line longer than the terminal width wraps to +// multiple physical rows, so the cursor must move up by PHYSICAL rows, not +// logical lines. With width 40, ["short", 100×'A'] occupies 1 + ceil(100/40)=3 +// = 4 physical rows; the redraw must emit \033[4F, not \033[2F. +func TestMultiLineRendererWidthAwareCursorUp(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true /*tty*/) + r.width = 40 // test seam (same package): pretend the terminal is 40 cols + + frame := []string{"short", strings.Repeat("A", 100)} + r.Render(frame) + buf.Reset() + r.Render(frame) + out := buf.String() + + if !strings.Contains(out, "\033[4F") { + t.Errorf("redraw must move up 4 physical rows (\\033[4F); got %q", out) + } + if strings.Contains(out, "\033[2F") { + t.Errorf("redraw must NOT move up by logical line count (\\033[2F); got %q", out) + } +} + +// TestVisibleWidthStripsANSI ensures colorized glyphs don't inflate the width +// (the step glyphs are color-wrapped, e.g. green ✓), which would otherwise +// over-count physical rows. +func TestVisibleWidthStripsANSI(t *testing.T) { + // "\033[32m✓\033[0m ok" → visible "✓ ok" = 4 runes. + if got := visibleWidth("\033[32m✓\033[0m ok"); got != 4 { + t.Errorf("visibleWidth = %d, want 4", got) + } + if got := visibleWidth("plain"); got != 5 { + t.Errorf("visibleWidth(plain) = %d, want 5", got) + } +} diff --git a/internal/tui/progress_tracker.go b/internal/tui/progress_tracker.go new file mode 100644 index 000000000..e92014f0e --- /dev/null +++ b/internal/tui/progress_tracker.go @@ -0,0 +1,299 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + + "github.com/fatih/color" +) + +// StepState mirrors Node's StepStatus enum (src/lib/cli/progress.ts:8). +type StepState string + +const ( + StepPending StepState = "pending" + StepRunning StepState = "running" + StepSuccess StepState = "success" + StepFailed StepState = "failed" + StepUnknown StepState = "unknown" + StepSkipped StepState = "skipped" +) + +// SpinnerGlyphs is Node's RUNNING_SPRITE_GLYPHS (src/lib/cli/format.ts:152). +var SpinnerGlyphs = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// GlyphForStatus mirrors Node format.ts getGlyphForStatus (format.ts:169). +// spinner is the current spinner glyph used for "running". +func GlyphForStatus(s StepState, spinner string) string { + switch s { + case StepPending: + return "○" + case StepRunning: + return color.HiBlueString(spinner) + case StepSuccess: + return color.GreenString("✓") + case StepFailed: + return color.RedString("✕") + case StepUnknown: + return color.YellowString("✕") + case StepSkipped: + return color.GreenString("-") + default: + return "" + } +} + +// ProgressStep seeds a caller-defined step. +type ProgressStep struct { + ID string + Name string +} + +// ServerStep is a server-reported step (Node's StepFromServer, +// progress.ts:30). +type ServerStep struct { + Name string + Status StepState +} + +type trackedStep struct { + id string + name string + status StepState + percentage string // upload step only (progress.ts:83) + progress string // generic per-step progress line (progress.ts:91) + additionalInfo []string // bullet lines under the step +} + +// ProgressTracker ports Node's ProgressTracker (src/lib/cli/progress.ts:35). +// Caller-defined steps render first, then server-reported steps — Node +// merges the two maps in that order (progress.ts:72). +// +// Safe for concurrent use: the upload progress callback fires from worker +// goroutines while a render ticker reads Frame(). +type ProgressTracker struct { + mu sync.Mutex + fromCaller []*trackedStep + fromServer []*trackedStep + spinnerIdx int + hasFailure bool + prefix string + suffix string +} + +// NewProgressTracker builds a tracker with the given caller-defined steps, +// all starting pending (progress.ts:76 mapSteps default). +func NewProgressTracker(steps []ProgressStep) *ProgressTracker { + pt := &ProgressTracker{} + for _, s := range steps { + pt.fromCaller = append(pt.fromCaller, &trackedStep{ + id: s.ID, name: s.Name, status: StepPending, + }) + } + return pt +} + +// SetPrefix sets the text printed before the step list (progress.ts:48). +func (pt *ProgressTracker) SetPrefix(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + pt.prefix = p +} + +// SetSuffix sets the text printed after the step list (progress.ts:51). +func (pt *ProgressTracker) SetSuffix(s string) { + pt.mu.Lock() + defer pt.mu.Unlock() + pt.suffix = s +} + +func (pt *ProgressTracker) find(id string) *trackedStep { + for _, s := range pt.fromCaller { + if s.id == id { + return s + } + } + return nil +} + +// setStatus mirrors setStatusForStepId (progress.ts:163). Completed steps +// (success/skipped — COMPLETED_STEP_SLUGS, progress.ts:17) reject further +// updates. Error strings are Node's exact messages. +func (pt *ProgressTracker) setStatus(id string, status StepState, info []string) error { + pt.mu.Lock() + defer pt.mu.Unlock() + s := pt.find(id) + if s == nil { + return fmt.Errorf("Step name %s is not valid.", id) + } + if s.status == StepSuccess || s.status == StepSkipped { + return fmt.Errorf("Step name %s is already completed.", id) + } + if status == StepFailed { + pt.hasFailure = true + } + s.status = status + s.additionalInfo = info + return nil +} + +func (pt *ProgressTracker) StepRunning(id string, info ...string) error { + return pt.setStatus(id, StepRunning, info) +} + +func (pt *ProgressTracker) StepFailed(id string, info ...string) error { + return pt.setStatus(id, StepFailed, info) +} + +func (pt *ProgressTracker) StepSkipped(id string, info ...string) error { + return pt.setStatus(id, StepSkipped, info) +} + +// StepSuccess marks id success and auto-promotes the next pending step to +// running (progress.ts:150). +func (pt *ProgressTracker) StepSuccess(id string, info ...string) error { + if err := pt.setStatus(id, StepSuccess, info); err != nil { + return err + } + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepPending { + s.status = StepRunning + break + } + } + return nil +} + +// SetUploadPercentage stores the percentage shown next to the "upload" +// step while it is running (progress.ts:83 setUploadPercentage). +func (pt *ProgressTracker) SetUploadPercentage(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + if s := pt.find("upload"); s != nil { + s.percentage = p + } +} + +// SetProgress stores a free-form progress string on the CURRENT running +// step (progress.ts:91 setProgress via getCurrentStep). No-op when no +// step is running. +func (pt *ProgressTracker) SetProgress(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepRunning { + s.progress = p + return + } + } +} + +// SetStepsFromServer replaces the server-step list. If no server step is +// running, the first pending one is promoted to running (progress.ts:100 +// setStepsFromServer). +func (pt *ProgressTracker) SetStepsFromServer(steps []ServerStep) { + pt.mu.Lock() + defer pt.mu.Unlock() + anyRunning := false + for _, s := range steps { + if s.Status == StepRunning { + anyRunning = true + break + } + } + out := make([]*trackedStep, 0, len(steps)) + promoted := false + for i, s := range steps { + st := s.Status + if !anyRunning && !promoted && st == StepPending { + st = StepRunning + promoted = true + } + out = append(out, &trackedStep{ + id: fmt.Sprintf("server-%d-%s", i, s.Name), + name: s.Name, + status: st, + }) + } + pt.fromServer = out +} + +// all returns caller steps followed by server steps. Caller must hold mu. +func (pt *ProgressTracker) all() []*trackedStep { + merged := make([]*trackedStep, 0, len(pt.fromCaller)+len(pt.fromServer)) + merged = append(merged, pt.fromCaller...) + merged = append(merged, pt.fromServer...) + return merged +} + +// AllStepsSucceeded mirrors allStepsSucceeded (progress.ts:159): every +// step (caller + server) must be success. +func (pt *ProgressTracker) AllStepsSucceeded() bool { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status != StepSuccess { + return false + } + } + return true +} + +func (pt *ProgressTracker) HasFailure() bool { + pt.mu.Lock() + defer pt.mu.Unlock() + return pt.hasFailure +} + +// CurrentStepID returns the id of the first running step ("" if none). +func (pt *ProgressTracker) CurrentStepID() string { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepRunning { + return s.id + } + } + return "" +} + +// Frame renders the current state as a multi-line block, one line per +// step (Node progress.ts:252 print()). Line shape is +// "<glyph> <name> <suffix>\n" — note the trailing space before an empty +// suffix, matching Node's `${statusIcon} ${name} ${suffix}\n`. The +// spinner advances one glyph per Frame call, mirroring +// RunningSprite.toString()'s advance-on-read (format.ts:160). +func (pt *ProgressTracker) Frame() string { + pt.mu.Lock() + defer pt.mu.Unlock() + spinner := SpinnerGlyphs[pt.spinnerIdx] + pt.spinnerIdx = (pt.spinnerIdx + 1) % len(SpinnerGlyphs) + + var b strings.Builder + b.WriteString(pt.prefix) + for _, s := range pt.all() { + suffix := "" + if s.id == "upload" { + if s.status == StepRunning && s.percentage != "" { + suffix = s.percentage + } + } else if s.progress != "" { + // progress.ts:270 — non-upload steps render their progress + // string whenever set, regardless of status. + suffix = s.progress + } + if len(s.additionalInfo) > 0 { + var infoLines []string + for _, info := range s.additionalInfo { + infoLines = append(infoLines, " - "+info) + } + suffix += "\n" + strings.Join(infoLines, "\n") + } + fmt.Fprintf(&b, "%s %s %s\n", GlyphForStatus(s.status, spinner), s.name, suffix) + } + b.WriteString(pt.suffix) + return b.String() +} diff --git a/internal/tui/progress_tracker_test.go b/internal/tui/progress_tracker_test.go new file mode 100644 index 000000000..1de3c4c7c --- /dev/null +++ b/internal/tui/progress_tracker_test.go @@ -0,0 +1,155 @@ +package tui + +import ( + "strings" + "testing" +) + +func steps3() []ProgressStep { + return []ProgressStep{ + {ID: "replace", Name: "Performing search and replace"}, + {ID: "upload", Name: "Uploading file"}, + {ID: "queue_import", Name: "Queueing import"}, + } +} + +func TestProgressTrackerFrameOrderAndGlyphs(t *testing.T) { + pt := NewProgressTracker(steps3()) + if err := pt.StepRunning("replace"); err != nil { + t.Fatal(err) + } + frame := pt.Frame() + lines := strings.Split(strings.TrimRight(frame, "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d: %q", len(lines), frame) + } + if !strings.Contains(lines[0], "Performing search and replace") { + t.Errorf("line 0 = %q", lines[0]) + } + // pending glyph is ○ (Node format.ts getGlyphForStatus) + if !strings.Contains(lines[1], "○") { + t.Errorf("pending glyph missing: %q", lines[1]) + } +} + +func TestProgressTrackerStepSuccessPromotesNext(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepRunning("replace") + if err := pt.StepSuccess("replace"); err != nil { + t.Fatal(err) + } + // Node progress.ts:150 — stepSuccess auto-promotes next pending to running. + if got := pt.CurrentStepID(); got != "upload" { + t.Errorf("current step = %q, want upload", got) + } +} + +func TestProgressTrackerCompletedStepRejected(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepSuccess("replace") + err := pt.StepRunning("replace") + if err == nil || !strings.Contains(err.Error(), "already completed") { + t.Errorf("want already-completed error, got %v", err) + } + if err := pt.StepRunning("nope"); err == nil || + !strings.Contains(err.Error(), "is not valid") { + t.Errorf("want invalid-step error, got %v", err) + } +} + +func TestProgressTrackerSkippedStepRejectsUpdates(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepSkipped("replace") + if err := pt.StepRunning("replace"); err == nil || + !strings.Contains(err.Error(), "already completed") { + t.Errorf("skipped step must reject updates (Node COMPLETED_STEP_SLUGS), got %v", err) + } +} + +func TestProgressTrackerUploadPercentageSuffix(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepRunning("upload") + pt.SetUploadPercentage("42%") + if frame := pt.Frame(); !strings.Contains(frame, "42%") { + t.Errorf("frame missing percentage: %q", frame) + } + // percentage only renders while running (progress.ts:266-268) + _ = pt.StepSuccess("upload") + if frame := pt.Frame(); strings.Contains(frame, "42%") { + t.Errorf("percentage must not render after success: %q", frame) + } +} + +func TestProgressTrackerServerStepsPromoteFirstPending(t *testing.T) { + pt := NewProgressTracker(nil) + pt.SetStepsFromServer([]ServerStep{ + {Name: "Import preflights", Status: StepSuccess}, + {Name: "Importing db", Status: StepPending}, + }) + // Node progress.ts:107 — no running step => first pending promoted. + frame := pt.Frame() + if !strings.Contains(frame, "Importing db") { + t.Fatalf("frame = %q", frame) + } + if pt.AllStepsSucceeded() { + t.Error("AllStepsSucceeded should be false with a pending step") + } + + pt.SetStepsFromServer([]ServerStep{ + {Name: "Import preflights", Status: StepSuccess}, + {Name: "Importing db", Status: StepSuccess}, + }) + if !pt.AllStepsSucceeded() { + t.Error("AllStepsSucceeded should be true when every step succeeded") + } +} + +func TestProgressTrackerHasFailure(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepFailed("upload") + if !pt.HasFailure() { + t.Error("HasFailure should be true") + } +} + +func TestProgressTrackerAdditionalInfoBullets(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepFailed("upload", "first detail", "second detail") + frame := pt.Frame() + if !strings.Contains(frame, " - first detail") || !strings.Contains(frame, " - second detail") { + t.Errorf("additionalInfo bullets missing: %q", frame) + } +} + +func TestProgressTrackerSetProgressOnRunningStep(t *testing.T) { + pt := NewProgressTracker([]ProgressStep{{ID: "download", Name: "Downloading file"}}) + _ = pt.StepRunning("download") + pt.SetProgress("- 42.00% (10 MB/24 MB)") + if frame := pt.Frame(); !strings.Contains(frame, "- 42.00% (10 MB/24 MB)") { + t.Errorf("frame = %q", frame) + } + // progress renders on non-upload steps regardless of status once set + // (progress.ts:270 `else if (progress)`). + _ = pt.StepSuccess("download") + if frame := pt.Frame(); !strings.Contains(frame, "42.00%") { + t.Errorf("progress must persist after success: %q", frame) + } +} + +func TestProgressTrackerSetProgressNoRunningStepIsNoop(t *testing.T) { + pt := NewProgressTracker(steps3()) + pt.SetProgress("- 10%") + if frame := pt.Frame(); strings.Contains(frame, "- 10%") { + t.Errorf("SetProgress without a running step must be a no-op (progress.ts:92): %q", frame) + } +} + +func TestProgressTrackerPrefixSuffix(t *testing.T) { + pt := NewProgressTracker(steps3()) + pt.SetPrefix("HEAD\n") + pt.SetSuffix("\nTAIL") + frame := pt.Frame() + if !strings.HasPrefix(frame, "HEAD\n") || !strings.HasSuffix(frame, "\nTAIL") { + t.Errorf("prefix/suffix not rendered: %q", frame) + } +} diff --git a/internal/upload/filemeta.go b/internal/upload/filemeta.go new file mode 100644 index 000000000..040cb9fde --- /dev/null +++ b/internal/upload/filemeta.go @@ -0,0 +1,112 @@ +package upload + +import ( + "compress/gzip" + "crypto/md5" // #nosec G501 -- S3 integrity checksum, Node parity, not a security boundary + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" +) + +// FileMeta mirrors Node's FileMeta (client-file-uploader.ts:48). +type FileMeta struct { + BaseName string + FileName string + FileSize int64 + IsCompressed bool +} + +// GetFileMeta ports getFileMeta (client-file-uploader.ts:144). +func GetFileMeta(fileName string) (FileMeta, error) { + fi, err := os.Stat(fileName) + if err != nil { + return FileMeta{}, err + } + mime, err := DetectCompressedMimeType(fileName) + if err != nil { + return FileMeta{}, err + } + return FileMeta{ + BaseName: filepath.Base(fileName), + FileName: fileName, + FileSize: fi.Size(), + IsCompressed: mime == "application/zip" || mime == "application/gzip", + }, nil +} + +// DetectCompressedMimeType ports detectCompressedMimeType +// (client-file-uploader.ts:458): sniff the first 4 bytes for the ZIP / +// GZIP magic numbers. Short files (<4 bytes) are fine — Node compares +// hex prefixes against whatever it managed to read, and so do we. +func DetectCompressedMimeType(fileName string) (string, error) { + f, err := os.Open(fileName) // #nosec G304 -- caller-supplied CLI path + if err != nil { + return "", err + } + defer f.Close() + buf := make([]byte, 4) + n, err := io.ReadFull(f, buf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", err + } + header := hex.EncodeToString(buf[:n]) + const zipMagic = "504b0304" + const gzMagic = "1f8b" + if len(header) >= len(zipMagic) && header[:len(zipMagic)] == zipMagic { + return "application/zip", nil + } + if len(header) >= len(gzMagic) && header[:len(gzMagic)] == gzMagic { + return "application/gzip", nil + } + return "", nil +} + +// FileHash ports getFileHash (client-file-uploader.ts:84): streamed +// md5/sha256 of the file contents, hex-encoded. Error wording matches +// Node's "Could not generate file hash: <cause>". +func FileHash(fileName, hashType string) (string, error) { + f, err := os.Open(fileName) // #nosec G304 + if err != nil { + return "", fmt.Errorf("Could not generate file hash: %s", err.Error()) + } + defer f.Close() + var h hash.Hash + switch hashType { + case "sha256": + h = sha256.New() + default: + h = md5.New() // #nosec G401 -- Node parity + } + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("Could not generate file hash: %s", err.Error()) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// GzipFile ports gzipFile (client-file-uploader.ts:102). Error wording +// matches Node's "Could not compress file: <cause>". +func GzipFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 + if err != nil { + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + defer in.Close() + out, err := os.Create(dst) // #nosec G304 + if err != nil { + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + zw := gzip.NewWriter(out) + if _, err := io.Copy(zw, in); err != nil { + out.Close() + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + if err := zw.Close(); err != nil { + out.Close() + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + return out.Close() +} diff --git a/internal/upload/filemeta_test.go b/internal/upload/filemeta_test.go new file mode 100644 index 000000000..c5e1789a1 --- /dev/null +++ b/internal/upload/filemeta_test.go @@ -0,0 +1,94 @@ +package upload + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func writeTemp(t *testing.T, name string, content []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestDetectCompressedMimeType(t *testing.T) { + gz := writeTemp(t, "x.bin", []byte{0x1f, 0x8b, 0x08, 0x00, 0x00}) + zip := writeTemp(t, "y.bin", []byte{0x50, 0x4b, 0x03, 0x04, 0x00}) + plain := writeTemp(t, "z.sql", []byte("SELECT 1;\n")) + short := writeTemp(t, "s.bin", []byte{0x1f, 0x8b}) + + for path, want := range map[string]string{ + gz: "application/gzip", zip: "application/zip", plain: "", short: "application/gzip", + } { + got, err := DetectCompressedMimeType(path) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("%s: got %q want %q", path, got, want) + } + } +} + +func TestGetFileMeta(t *testing.T) { + p := writeTemp(t, "dump.sql", []byte("CREATE TABLE wp_posts;\n")) + meta, err := GetFileMeta(p) + if err != nil { + t.Fatal(err) + } + if meta.BaseName != "dump.sql" || meta.IsCompressed || meta.FileSize != 23 { + t.Errorf("meta = %+v", meta) + } +} + +func TestFileHashMD5(t *testing.T) { + p := writeTemp(t, "h.txt", []byte("hello")) + got, err := FileHash(p, "md5") + if err != nil { + t.Fatal(err) + } + if got != "5d41402abc4b2a76b9719d911017c592" { + t.Errorf("md5 = %q", got) + } +} + +func TestFileHashSHA256(t *testing.T) { + p := writeTemp(t, "h.txt", []byte("hello")) + got, err := FileHash(p, "sha256") + if err != nil { + t.Fatal(err) + } + if got != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" { + t.Errorf("sha256 = %q", got) + } +} + +func TestGzipFileRoundTrip(t *testing.T) { + src := writeTemp(t, "in.sql", bytes.Repeat([]byte("a"), 4096)) + dst := filepath.Join(t.TempDir(), "out.sql.gz") + if err := GzipFile(src, dst); err != nil { + t.Fatal(err) + } + f, err := os.Open(dst) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if _, err := out.ReadFrom(zr); err != nil { + t.Fatal(err) + } + if out.Len() != 4096 { + t.Errorf("round-trip len = %d", out.Len()) + } +} diff --git a/internal/upload/multipart.go b/internal/upload/multipart.go new file mode 100644 index 000000000..0252bdf81 --- /dev/null +++ b/internal/upload/multipart.go @@ -0,0 +1,212 @@ +package upload + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" +) + +// initiateResult is S3's CreateMultipartUpload response +// (client-file-uploader.ts:328). +type initiateResult struct { + XMLName xml.Name `xml:"InitiateMultipartUploadResult"` + UploadId string `xml:"UploadId"` +} + +// etagResult is one element of the CompleteMultipartUpload payload +// (client-file-uploader.ts:664). +type etagResult struct { + ETag string + PartNumber int +} + +// uploadUsingMultipart ports uploadUsingMultipart +// (client-file-uploader.ts:338). partSize is parameterized for tests; +// production passes UploadPartSize. +func (c *Client) uploadUsingMultipart(ctx context.Context, appID, envID int64, meta FileMeta, partSize int64, progressCb func(string)) (string, error) { + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "CreateMultipartUpload", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + }) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, nil) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + resp, err := c.doWithRetry(req, nil) + if err != nil { + return "", err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var initErr s3Error + if xml.Unmarshal(body, &initErr) == nil && initErr.Code != "" { + // Node: "Unable to create cloud storage object. Error: ..." (ts:373) + return "", fmt.Errorf("Unable to create cloud storage object. Error: %s", + fmt.Sprintf(`{"Code":%q,"Message":%q}`, initErr.Code, initErr.Message)) + } + var init initiateResult + if err := xml.Unmarshal(body, &init); err != nil || init.UploadId == "" { + // Node: "Unable to get Upload ID from cloud storage. Error: <raw>" (ts:382) + return "", fmt.Errorf("Unable to get Upload ID from cloud storage. Error: %s", body) + } + + parts, err := getPartBoundariesWithSize(meta.FileSize, partSize) + if err != nil { + return "", err + } + etags, err := c.uploadParts(ctx, appID, envID, meta, init.UploadId, parts, progressCb) + if err != nil { + return "", err + } + return c.completeMultipartUpload(ctx, appID, envID, meta.BaseName, init.UploadId, etags) +} + +// uploadParts ports uploadParts (client-file-uploader.ts:517): bounded +// concurrency (MaxConcurrentPartUploads), shared total-bytes counter +// feeding the progress callback. +func (c *Client) uploadParts(ctx context.Context, appID, envID int64, meta FileMeta, uploadID string, parts []PartBoundary, progressCb func(string)) ([]etagResult, error) { + sem := make(chan struct{}, MaxConcurrentPartUploads) + results := make([]etagResult, len(parts)) + errs := make([]error, len(parts)) + var totalRead atomic.Int64 + var wg sync.WaitGroup + + for i := range parts { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + etag, err := c.uploadPart(ctx, appID, envID, meta, parts[idx], uploadID, &totalRead, progressCb) + if err != nil { + errs[idx] = err + return + } + results[idx] = etagResult{ETag: etag, PartNumber: parts[idx].Index + 1} + }(i) + } + wg.Wait() + for _, err := range errs { + if err != nil { + return nil, err + } + } + return results, nil +} + +// uploadPart ports uploadPart (client-file-uploader.ts:606): per-part +// presigned PUT of the byte range [Start, End]; the quoted ETag response +// header is unquoted (Node JSON.parse's it — ts:646). +func (c *Client) uploadPart(ctx context.Context, appID, envID int64, meta FileMeta, part PartBoundary, uploadID string, totalRead *atomic.Int64, progressCb func(string)) (string, error) { + s3PartNumber := part.Index + 1 // S3 multipart is 1-indexed (ts:615) + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "UploadPart", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + PartNumber: s3PartNumber, UploadID: uploadID, + }) + if err != nil { + return "", err + } + + makeBody := func() (io.ReadCloser, error) { + f, err := os.Open(meta.FileName) // #nosec G304 + if err != nil { + return nil, err + } + if _, err := f.Seek(part.Start, io.SeekStart); err != nil { + f.Close() + return nil, err + } + return readCloser{ + Reader: &progressReader{ + r: io.LimitReader(f, part.PartSize), + total: meta.FileSize, + read: totalRead, + cb: progressCb, + }, + closer: f, + }, nil + } + + body, err := makeBody() + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, body) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + req.Header.Set("Content-Length", fmt.Sprintf("%d", part.PartSize)) // ts:631 + req.ContentLength = part.PartSize + + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return strings.Trim(resp.Header.Get("ETag"), `"`), nil + } + respBody, _ := io.ReadAll(resp.Body) + // Node: "Unable to upload file part. Error: ..." (ts:659) + return "", fmt.Errorf("Unable to upload file part. Error: %s", formatS3Error(respBody, resp)) +} + +// completeMultipartUpload ports completeMultipartUpload +// (client-file-uploader.ts:696). Returns the raw XML success body (Node +// returns the parsed doc; only its presence matters to callers). +func (c *Client) completeMultipartUpload(ctx context.Context, appID, envID int64, basename, uploadID string, etags []etagResult) (string, error) { + etagMaps := make([]map[string]any, len(etags)) + for i, e := range etags { + etagMaps[i] = map[string]any{"ETag": e.ETag, "PartNumber": e.PartNumber} + } + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "CompleteMultipartUpload", AppID: appID, EnvID: envID, + BaseName: basename, UploadID: uploadID, EtagResults: etagMaps, + }) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, strings.NewReader(pre.Options.Body)) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + makeBody := func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(pre.Options.Body)), nil + } + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + // Node: throw await response.text() — a bare string (ts:719). + return "", fmt.Errorf("%s", body) + } + // S3 can return 200 with an <Error> body for CompleteMultipartUpload + // (ts:722 comment block). + var compErr s3Error + if xml.Unmarshal(body, &compErr) == nil && compErr.Code != "" { + return "", fmt.Errorf("Unable to complete the upload. Error: %s", + fmt.Sprintf(`{"Code":%q,"Message":%q}`, compErr.Code, compErr.Message)) + } + return string(body), nil +} diff --git a/internal/upload/multipart_test.go b/internal/upload/multipart_test.go new file mode 100644 index 000000000..2556e21c1 --- /dev/null +++ b/internal/upload/multipart_test.go @@ -0,0 +1,210 @@ +package upload + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + json "encoding/json/v2" +) + +// multipartStub implements presign + CreateMultipartUpload + UploadPart + +// CompleteMultipartUpload endpoints. +type multipartStub struct { + t *testing.T + mu sync.Mutex + parts map[int][]byte + maxInFlight int32 + inFlight int32 + failPart2 int32 // fail part #2 this many times (network-level close) + complete []byte + completeBody []byte +} + +const signedCompleteMultipartBody = `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Part><ETag>etag-1</ETag><PartNumber>1</PartNumber></Part><Part><ETag>etag-2</ETag><PartNumber>2</PartNumber></Part><Part><ETag>etag-3</ETag><PartNumber>3</PartNumber></Part></CompleteMultipartUpload>` + +func newMultipartTest(t *testing.T) (*multipartStub, *Client) { + st := &multipartStub{t: t, parts: map[int][]byte{}} + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + var args SignedRequestArgs + b, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(b, &args); err != nil { + st.t.Errorf("bad presign body: %v", err) + } + switch args.Action { + case "CreateMultipartUpload": + fmt.Fprintf(w, `{"url":"%s/s3create","options":{"method":"POST","headers":{}}}`, srv.URL) + case "UploadPart": + fmt.Fprintf(w, `{"url":"%s/s3part/%d","options":{"method":"PUT","headers":{}}}`, srv.URL, args.PartNumber) + case "CompleteMultipartUpload": + st.mu.Lock() + st.complete = b + st.mu.Unlock() + fmt.Fprintf(w, `{"url":"%s/s3complete","options":{"method":"POST","headers":{"Content-Length":"%d","Content-Type":"application/xml"},"body":%q}}`, srv.URL, len(signedCompleteMultipartBody), signedCompleteMultipartBody) + default: + st.t.Errorf("unexpected action %q", args.Action) + } + }) + mux.HandleFunc("/s3create", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`<?xml version="1.0"?><InitiateMultipartUploadResult><Bucket>b</Bucket><Key>k</Key><UploadId>UPLOAD123</UploadId></InitiateMultipartUploadResult>`)) + }) + mux.HandleFunc("/s3part/", func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&st.inFlight, 1) + defer atomic.AddInt32(&st.inFlight, -1) + for { + max := atomic.LoadInt32(&st.maxInFlight) + if cur <= max || atomic.CompareAndSwapInt32(&st.maxInFlight, max, cur) { + break + } + } + var n int + _, _ = fmt.Sscanf(r.URL.Path, "/s3part/%d", &n) + if n == 2 && atomic.AddInt32(&st.failPart2, -1) >= 0 { + // network-level failure: hijack + close so the client sees a + // transport error (the only thing fetch-retry retries). + hj, ok := w.(http.Hijacker) + if !ok { + st.t.Fatal("hijack unsupported") + } + conn, _, err := hj.Hijack() + if err != nil { + st.t.Fatal(err) + } + conn.Close() + return + } + body, _ := io.ReadAll(r.Body) + st.mu.Lock() + st.parts[n] = body + st.mu.Unlock() + w.Header().Set("ETag", fmt.Sprintf(`"etag-%d"`, n)) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/s3complete", func(w http.ResponseWriter, r *http.Request) { + st.mu.Lock() + st.completeBody, _ = io.ReadAll(r.Body) + st.mu.Unlock() + _, _ = w.Write([]byte(`<?xml version="1.0"?><CompleteMultipartUploadResult><Location>l</Location><Bucket>b</Bucket><Key>k</Key><ETag>"final"</ETag></CompleteMultipartUploadResult>`)) + }) + return st, &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client(), + retryDelay: func(int) time.Duration { return 0 }} +} + +func TestMultipartHappyPath(t *testing.T) { + st, c := newMultipartTest(t) + content := bytes.Repeat([]byte("x"), 40) // partSize 16 → 3 parts: 16,16,8 + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if len(st.parts[1]) != 16 || len(st.parts[2]) != 16 || len(st.parts[3]) != 8 { + t.Errorf("part sizes: %d/%d/%d", len(st.parts[1]), len(st.parts[2]), len(st.parts[3])) + } + comp := string(st.complete) + if !strings.Contains(comp, `"ETag":"etag-1"`) || !strings.Contains(comp, `"PartNumber":3`) { + t.Errorf("complete body = %s", comp) + } + if got := string(st.completeBody); got != signedCompleteMultipartBody { + t.Errorf("S3 completion body = %q, want signed body %q", got, signedCompleteMultipartBody) + } +} + +func TestMultipartPartRetrySucceeds(t *testing.T) { + st, c := newMultipartTest(t) + atomic.StoreInt32(&st.failPart2, 2) // fail part 2 twice, succeed third + content := bytes.Repeat([]byte("y"), 40) + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if len(st.parts[2]) != 16 { + t.Errorf("part 2 not uploaded after retries") + } +} + +func TestMultipartPartRetryExhausts(t *testing.T) { + st, c := newMultipartTest(t) + atomic.StoreInt32(&st.failPart2, 99) // never recovers + content := bytes.Repeat([]byte("y"), 40) + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err == nil { + t.Fatal("want error after retry exhaustion") + } +} + +func TestMultipartConcurrencyCap(t *testing.T) { + st, c := newMultipartTest(t) + content := bytes.Repeat([]byte("z"), 16*12) // 12 parts + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(&st.maxInFlight); got > MaxConcurrentPartUploads { + t.Errorf("max in-flight = %d, want <= %d", got, MaxConcurrentPartUploads) + } +} + +func TestUploadImportFileGzRename(t *testing.T) { + for in, want := range map[string]string{ + "dump.sql": "dump.sql.gz", + "dump.sql.gz": "dump.sql.gz", + "DUMP.SQL.GZ": "DUMP.SQL.gz", + } { + if got := gzRename(in); got != want { + t.Errorf("gzRename(%q) = %q, want %q", in, got, want) + } + } +} + +func TestUploadImportFileSmallUsesPutObject(t *testing.T) { + var sawPut bool + c := stubPresignServer(t, func(w http.ResponseWriter, r *http.Request) { + sawPut = true + w.WriteHeader(http.StatusOK) + }) + p := writeTemp(t, "dump.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + res, err := c.UploadImportFile(context.Background(), 1, 2, meta, "md5", nil) + if err != nil { + t.Fatal(err) + } + if !sawPut { + t.Error("small file must take the PutObject path") + } + if res.Meta.BaseName != "dump.sql" || res.Meta.IsCompressed { + t.Errorf("meta = %+v (small file must not be compressed)", res.Meta) + } + if len(res.Checksum) != 32 { + t.Errorf("checksum = %q", res.Checksum) + } +} diff --git a/internal/upload/orchestrate.go b/internal/upload/orchestrate.go new file mode 100644 index 000000000..240f4debe --- /dev/null +++ b/internal/upload/orchestrate.go @@ -0,0 +1,71 @@ +package upload + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// UploadResult mirrors uploadImportFileToS3's return +// (client-file-uploader.ts:222). +type UploadResult struct { + Meta FileMeta + Checksum string // hex md5 (or sha256) of the file as uploaded + Result string +} + +// gzRename mirrors Node's basename.replace(/(.gz)?$/i, '.gz') (ts:193): +// idempotently ensure a single .gz suffix, replacing an existing +// (case-insensitive) one. +func gzRename(base string) string { + if l := strings.ToLower(base); strings.HasSuffix(l, ".gz") { + base = base[:len(base)-3] + } + return base + ".gz" +} + +// UploadImportFile ports uploadImportFileToS3 (client-file-uploader.ts:163): +// 1. gzip-compress when not already compressed and >= CompressThreshold, +// 2. checksum the (possibly compressed) file, +// 3. PutObject below MultipartThreshold, multipart at/above it. +func (c *Client) UploadImportFile(ctx context.Context, appID, envID int64, meta FileMeta, hashType string, progressCb func(string)) (*UploadResult, error) { + if !meta.IsCompressed && meta.FileSize >= CompressThreshold { + tmpDir, err := os.MkdirTemp("", "vip-client-file-uploader") + if err != nil { + return nil, fmt.Errorf("Unable to create temporary working directory: %s", err.Error()) + } + meta.BaseName = gzRename(meta.BaseName) + compressed := filepath.Join(tmpDir, meta.BaseName) + if err := GzipFile(meta.FileName, compressed); err != nil { + return nil, err + } + meta.FileName = compressed + meta.IsCompressed = true + fi, err := os.Stat(compressed) + if err != nil { + return nil, err + } + meta.FileSize = fi.Size() + } + + if hashType == "" { + hashType = "md5" + } + checksum, err := FileHash(meta.FileName, hashType) + if err != nil { + return nil, err + } + + var result string + if meta.FileSize < MultipartThreshold { + result, err = c.uploadUsingPutObject(ctx, appID, envID, meta, progressCb) + } else { + result, err = c.uploadUsingMultipart(ctx, appID, envID, meta, UploadPartSize, progressCb) + } + if err != nil { + return nil, err + } + return &UploadResult{Meta: meta, Checksum: checksum, Result: result}, nil +} diff --git a/internal/upload/parts.go b/internal/upload/parts.go new file mode 100644 index 000000000..e7da5a11b --- /dev/null +++ b/internal/upload/parts.go @@ -0,0 +1,39 @@ +package upload + +import "errors" + +// PartBoundary mirrors Node's PartBoundaries (client-file-uploader.ts:479). +// End is inclusive, like Node's createReadStream({start, end}) range. +type PartBoundary struct { + Start int64 + End int64 + Index int + PartSize int64 +} + +// GetPartBoundaries ports getPartBoundaries (client-file-uploader.ts:485). +func GetPartBoundaries(fileSize int64) ([]PartBoundary, error) { + return getPartBoundariesWithSize(fileSize, UploadPartSize) +} + +// getPartBoundariesWithSize is GetPartBoundaries with an explicit part +// size so tests don't need 16MB fixtures. +func getPartBoundariesWithSize(fileSize, partSize int64) ([]PartBoundary, error) { + if fileSize < 1 { + return nil, errors.New("fileSize must be greater than zero") + } + numParts := (fileSize + partSize - 1) / partSize + parts := make([]PartBoundary, 0, numParts) + for i := int64(0); i < numParts; i++ { + start := i * partSize + remaining := fileSize - start + end := start + partSize - 1 + if remaining <= partSize { + end = start + remaining - 1 + } + parts = append(parts, PartBoundary{ + Start: start, End: end, Index: int(i), PartSize: end + 1 - start, + }) + } + return parts, nil +} diff --git a/internal/upload/parts_test.go b/internal/upload/parts_test.go new file mode 100644 index 000000000..0ef9c6359 --- /dev/null +++ b/internal/upload/parts_test.go @@ -0,0 +1,39 @@ +package upload + +import "testing" + +func TestGetPartBoundaries(t *testing.T) { + // Node client-file-uploader.ts:485. UploadPartSize = 16 MiB. + const mb = int64(1024 * 1024) + cases := []struct { + name string + fileSize int64 + want []PartBoundary + }{ + {"one byte", 1, []PartBoundary{{Start: 0, End: 0, Index: 0, PartSize: 1}}}, + {"exactly one part", 16 * mb, []PartBoundary{{Start: 0, End: 16*mb - 1, Index: 0, PartSize: 16 * mb}}}, + {"one part plus one byte", 16*mb + 1, []PartBoundary{ + {Start: 0, End: 16*mb - 1, Index: 0, PartSize: 16 * mb}, + {Start: 16 * mb, End: 16 * mb, Index: 1, PartSize: 1}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := GetPartBoundaries(tc.fileSize) + if err != nil { + t.Fatal(err) + } + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d", len(got), len(tc.want)) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("part %d = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } + if _, err := GetPartBoundaries(0); err == nil { + t.Error("fileSize 0 should error (Node: 'fileSize must be greater than zero')") + } +} diff --git a/internal/upload/presign.go b/internal/upload/presign.go new file mode 100644 index 000000000..3820a39f5 --- /dev/null +++ b/internal/upload/presign.go @@ -0,0 +1,103 @@ +package upload + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Client issues presigned-request lookups against the VIP API and the +// resulting S3 uploads. APIHost/Token come from commands.GetConfig(); +// HTTPClient defaults to httpproxy.Client(). +type Client struct { + APIHost string + Token string + HTTPClient *http.Client + // retryDelay overrides the backoff in tests. nil = Node's + // 2^attempt * 1s (fetch-retry config, client-file-uploader.ts:24). + retryDelay func(attempt int) time.Duration +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + // NOT http.DefaultClient: the presign call carries the bearer token (or + // WPVIP_DEPLOY_TOKEN) and the S3 PUTs carry a presigned URL whose query + // string is itself the credential. See internal/httpproxy. + return httpproxy.Client() +} + +// SignedRequestArgs ports GetSignedUploadRequestDataArgs +// (client-file-uploader.ts:56). EtagResults is the multipart completion +// payload: a list of {"ETag": ..., "PartNumber": ...} objects. +type SignedRequestArgs struct { + Action string `json:"action"` + AppID int64 `json:"appId"` + EnvID int64 `json:"envId"` + BaseName string `json:"basename"` + EtagResults []map[string]any `json:"etagResults,omitempty"` + PartNumber int `json:"partNumber,omitempty"` + UploadID string `json:"uploadId,omitempty"` +} + +// PresignedRequest mirrors Node's PresignedRequest +// (client-file-uploader.ts:236). +type PresignedRequest struct { + URL string `json:"url"` + Options struct { + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Body string `json:"body,omitempty"` + } `json:"options"` +} + +// GetSignedUploadRequestData ports getSignedUploadRequestData +// (client-file-uploader.ts:411): POST /upload/site-import-presigned-url +// with the CLI token, or WPVIP_DEPLOY_TOKEN when set (ts:420 — the +// deploy-token bypass skips the keychain credential entirely). +func (c *Client) GetSignedUploadRequestData(ctx context.Context, args SignedRequestArgs) (*PresignedRequest, error) { + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.APIHost+"/upload/site-import-presigned-url", bytes.NewReader(body)) + if err != nil { + return nil, err + } + token := c.Token + if t := os.Getenv("WPVIP_DEPLOY_TOKEN"); t != "" { + token = t + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // Node: throw new Error((await response.text()) || statusText) + // — client-file-uploader.ts:433. + text, _ := io.ReadAll(resp.Body) + if len(text) > 0 { + return nil, fmt.Errorf("%s", text) + } + return nil, fmt.Errorf("%s", resp.Status) + } + var pr PresignedRequest + if err := json.UnmarshalRead(resp.Body, &pr); err != nil { + return nil, err + } + return &pr, nil +} diff --git a/internal/upload/presign_test.go b/internal/upload/presign_test.go new file mode 100644 index 000000000..388d51f61 --- /dev/null +++ b/internal/upload/presign_test.go @@ -0,0 +1,128 @@ +package upload + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + json "encoding/json/v2" +) + +func TestGetSignedUploadRequestData(t *testing.T) { + var gotBody map[string]any + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/upload/site-import-presigned-url" { + t.Errorf("path = %s", r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"url":"https://s3.example/x","options":{"method":"PUT","headers":{"X-Amz-Meta":"1"}}}`)) + })) + defer srv.Close() + + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + req, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "dump.sql", + }) + if err != nil { + t.Fatal(err) + } + if req.URL != "https://s3.example/x" || req.Options.Method != "PUT" { + t.Errorf("req = %+v", req) + } + if req.Options.Headers["X-Amz-Meta"] != "1" { + t.Errorf("headers = %v", req.Options.Headers) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth = %q", gotAuth) + } + if gotBody["action"] != "PutObject" || gotBody["basename"] != "dump.sql" { + t.Errorf("body = %v", gotBody) + } +} + +func TestGetSignedUploadRequestDataDeployTokenOverride(t *testing.T) { + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer deploy-tok" { + t.Errorf("auth = %q", got) + } + _, _ = w.Write([]byte(`{"url":"u","options":{"method":"PUT","headers":{}}}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + if _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "x", + }); err != nil { + t.Fatal(err) + } +} + +func TestGetSignedUploadRequestDataNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no can do", http.StatusForbidden) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "x", + }) + // Node: throw new Error(await response.text() || statusText) — ts:433. + // http.Error appends a newline; the body is used verbatim. + if err == nil || err.Error() != "no can do\n" { + t.Errorf("err = %v", err) + } +} + +func TestDoWithRetryRetriesNetworkErrorsOnly(t *testing.T) { + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := &Client{HTTPClient: srv.Client(), retryDelay: func(int) time.Duration { return 0 }} + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + resp, err := c.doWithRetry(req, nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + // fetch-retry's default retryOn does NOT retry on HTTP status — only + // network errors. 500 must come back after exactly 1 attempt. + if attempts != 1 { + t.Errorf("attempts = %d, want 1 (no status-code retries)", attempts) + } + if resp.StatusCode != 500 { + t.Errorf("status = %d", resp.StatusCode) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestDoWithRetryNetworkErrorExhaustsAfter4(t *testing.T) { + attempts := 0 + c := &Client{ + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return nil, io.ErrUnexpectedEOF + })}, + retryDelay: func(int) time.Duration { return 0 }, + } + req, _ := http.NewRequest(http.MethodGet, "http://example.invalid", nil) + if _, err := c.doWithRetry(req, nil); err == nil { + t.Fatal("want error") + } + // retries: 3 → 4 total attempts (fetch-retry semantics) + if attempts != 4 { + t.Errorf("attempts = %d, want 4", attempts) + } +} diff --git a/internal/upload/proxy_test.go b/internal/upload/proxy_test.go new file mode 100644 index 000000000..a3f64c9f9 --- /dev/null +++ b/internal/upload/proxy_test.go @@ -0,0 +1,55 @@ +package upload + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +// TestPresignRequestHonoursVIPProxy pins cutover item 2.14 on the second path +// that carries the bearer token: POST /upload/site-import-presigned-url. The +// default client was http.DefaultClient, which ignores VIP_PROXY/SOCKS_PROXY +// and honours HTTPS_PROXY without the VIP_USE_SYSTEM_PROXY opt-in Node requires. +// +// Live loopback target, closed SOCKS port: no Go proxy resolver would ever +// proxy a loopback host, so reaching the server proves the request went direct. +func TestPresignRequestHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"url":"https://s3.example/x","options":{"method":"PUT","headers":{}}}`)) + })) + defer srv.Close() + + for _, k := range []string{ + "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY", "vip_proxy", + "WPVIP_DEPLOY_TOKEN", + } { + t.Setenv(k, "") + } + t.Setenv("VIP_PROXY", "socks5://"+closedProxyAddr(t)) + + c := &Client{APIHost: srv.URL, Token: "bearer-token-under-test"} + _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "AssertMultipartUpload", AppID: 1, EnvID: 2, BaseName: "x.sql", + }) + if err == nil { + t.Fatal("presign request succeeded; VIP_PROXY was ignored and the bearer token went direct") + } +} + +func closedProxyAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return addr +} diff --git a/internal/upload/putobject.go b/internal/upload/putobject.go new file mode 100644 index 000000000..cb6436477 --- /dev/null +++ b/internal/upload/putobject.go @@ -0,0 +1,89 @@ +package upload + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "sync/atomic" +) + +// progressReader counts bytes read and reports floor(100*read/total)% via +// cb — the PassThrough 'data' handler in Node (client-file-uploader.ts:277). +// read is shared across parts in multipart mode so the percentage reflects +// overall progress (ts:570 totalBytesRead). +type progressReader struct { + r io.Reader + total int64 + read *atomic.Int64 + cb func(percentage string) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 && p.read != nil { + read := p.read.Add(int64(n)) + if p.cb != nil && p.total > 0 { + p.cb(fmt.Sprintf("%d%%", 100*read/p.total)) + } + } + return n, err +} + +type readCloser struct { + io.Reader + closer io.Closer +} + +func (rc readCloser) Close() error { return rc.closer.Close() } + +// uploadUsingPutObject ports uploadUsingPutObject +// (client-file-uploader.ts:255). Returns "ok" on HTTP 200, otherwise an +// error wrapping the S3 <Error> payload. +func (c *Client) uploadUsingPutObject(ctx context.Context, appID, envID int64, meta FileMeta, progressCb func(string)) (string, error) { + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "PutObject", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + }) + if err != nil { + return "", err + } + + makeBody := func() (io.ReadCloser, error) { + f, err := os.Open(meta.FileName) // #nosec G304 + if err != nil { + return nil, err + } + var counter atomic.Int64 + return readCloser{ + Reader: &progressReader{r: f, total: meta.FileSize, read: &counter, cb: progressCb}, + closer: f, + }, nil + } + + body, err := makeBody() + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, body) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + // Node forces Content-Length as a string header (ts:273). + req.Header.Set("Content-Length", fmt.Sprintf("%d", meta.FileSize)) + req.ContentLength = meta.FileSize + + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return "ok", nil + } + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("Unable to upload to cloud storage. %s", formatS3Error(respBody, resp)) +} diff --git a/internal/upload/putobject_test.go b/internal/upload/putobject_test.go new file mode 100644 index 000000000..b296d12ef --- /dev/null +++ b/internal/upload/putobject_test.go @@ -0,0 +1,79 @@ +package upload + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// stubPresignServer serves both the presign endpoint and the "S3" target. +func stubPresignServer(t *testing.T, s3Handler http.HandlerFunc) *Client { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"url":"` + srv.URL + `/s3target","options":{"method":"PUT","headers":{}}}`)) + }) + mux.HandleFunc("/s3target", s3Handler) + return &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} +} + +func TestUploadUsingPutObjectOK(t *testing.T) { + var gotLen string + var gotBody []byte + c := stubPresignServer(t, func(w http.ResponseWriter, r *http.Request) { + gotLen = r.Header.Get("Content-Length") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + var lastPct string + result, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, + func(pct string) { lastPct = pct }) + if err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Errorf("result = %q", result) + } + if gotLen != "10" || string(gotBody) != "SELECT 1;\n" { + t.Errorf("len=%q body=%q", gotLen, gotBody) + } + if lastPct != "100%" { + t.Errorf("last pct = %q", lastPct) + } +} + +func TestUploadUsingPutObjectS3Error(t *testing.T) { + c := stubPresignServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`<?xml version="1.0"?><Error><Code>AccessDenied</Code><Message>Denied</Message></Error>`)) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + _, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, nil) + want := `Unable to upload to cloud storage. {"Code":"AccessDenied","Message":"Denied"}` + if err == nil || err.Error() != want { + t.Errorf("err = %v\nwant %s", err, want) + } +} + +func TestUploadUsingPutObjectNonXMLError(t *testing.T) { + c := stubPresignServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream had a bad day")) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + _, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, nil) + // Node falls back to {Code: "HTTP Error <status>", Message: statusText} + // when the body isn't an <Error> doc (ts:315-320). + if err == nil || !strings.Contains(err.Error(), "HTTP Error 502") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/upload/retry.go b/internal/upload/retry.go new file mode 100644 index 000000000..042dd844e --- /dev/null +++ b/internal/upload/retry.go @@ -0,0 +1,45 @@ +package upload + +import ( + "io" + "net/http" + "time" +) + +// maxRetries mirrors fetch-retry's `retries: 3` (client-file-uploader.ts:23). +const maxRetries = 3 + +// doWithRetry replicates fetch-retry's defaults as configured in Node: +// retry on transport (network) errors only — NOT on HTTP status codes +// (fetch-retry's default retryOn is empty) — up to maxRetries extra +// attempts, sleeping 2^attempt seconds between tries (1s, 2s, 4s; +// client-file-uploader.ts:24). makeBody, when non-nil, recreates the +// request body before each retry attempt (streaming bodies are consumed +// by failed attempts). +func (c *Client) doWithRetry(req *http.Request, makeBody func() (io.ReadCloser, error)) (*http.Response, error) { + delay := c.retryDelay + if delay == nil { + delay = func(attempt int) time.Duration { + return time.Duration(1<<uint(attempt)) * time.Second + } + } + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(delay(attempt - 1)) + if makeBody != nil { + body, err := makeBody() + if err != nil { + return nil, err + } + req.Body = body + } + } + resp, err := c.httpClient().Do(req) + if err == nil { + return resp, nil + } + lastErr = err + } + return nil, lastErr +} diff --git a/internal/upload/upload.go b/internal/upload/upload.go new file mode 100644 index 000000000..eb4b33d8b --- /dev/null +++ b/internal/upload/upload.go @@ -0,0 +1,22 @@ +// Package upload ports src/lib/client-file-uploader.ts: streamed S3 +// uploads via presigned requests obtained from the VIP API. Strict Node +// parity: no resume cache; 3 network-error retries with 1s/2s/4s backoff; +// gzip-compress files >= CompressThreshold before upload; PutObject below +// MultipartThreshold, S3 multipart at/above it with 5 concurrent part +// workers. +package upload + +const ( + mbInBytes = 1024 * 1024 + + // CompressThreshold — client-file-uploader.ts:32. Files at/above this + // size that are not already compressed get gzipped before upload. + CompressThreshold = 16 * mbInBytes + // MultipartThreshold — client-file-uploader.ts:35. Files below this + // size use PutObject; at/above use the S3 multipart API. + MultipartThreshold = 32 * mbInBytes + // UploadPartSize — client-file-uploader.ts:38. + UploadPartSize = 16 * mbInBytes + // MaxConcurrentPartUploads — client-file-uploader.ts:41. + MaxConcurrentPartUploads = 5 +) diff --git a/internal/upload/xmlerror.go b/internal/upload/xmlerror.go new file mode 100644 index 000000000..493c027ec --- /dev/null +++ b/internal/upload/xmlerror.go @@ -0,0 +1,28 @@ +package upload + +import ( + "encoding/xml" + "fmt" + "net/http" +) + +// s3Error is the body S3 returns on failure (Node parses with xml2js; +// only Code and Message are consumed — client-file-uploader.ts:246). +type s3Error struct { + XMLName xml.Name `xml:"Error"` + Code string `xml:"Code"` + Message string `xml:"Message"` +} + +// formatS3Error renders the {"Code":...,"Message":...} fragment Node +// builds with JSON.stringify({ Code, Message }) — client-file-uploader.ts:322. +func formatS3Error(body []byte, resp *http.Response) string { + var e s3Error + if err := xml.Unmarshal(body, &e); err == nil && e.Code != "" { + return fmt.Sprintf(`{"Code":%q,"Message":%q}`, e.Code, e.Message) + } + // Node: Code = `HTTP Error <status>`, Message = statusText (ts:318). + return fmt.Sprintf(`{"Code":%q,"Message":%q}`, + fmt.Sprintf("HTTP Error %d", resp.StatusCode), + http.StatusText(resp.StatusCode)) +} diff --git a/internal/validatefiles/files.go b/internal/validatefiles/files.go new file mode 100644 index 000000000..f43e70270 --- /dev/null +++ b/internal/validatefiles/files.go @@ -0,0 +1,105 @@ +package validatefiles + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +// FileValidationResult mirrors ValidationResult (ts:36). +type FileValidationResult struct { + IntermediateImagesTotal int + ErrorFileTypes []string + ErrorFileNames []string + ErrorFileSizes []string + ErrorFileNamesCharCount []string + IntermediateImages map[string]string // original -> "im1, im2" +} + +// ValidateFiles ports validateFiles (ts:50): per-file extension, size, +// sanitized-name, name-length, and intermediate-image checks. +func ValidateFiles(files []string, cfg Config) FileValidationResult { + res := FileValidationResult{IntermediateImages: map[string]string{}} + for _, file := range files { + fi, statErr := os.Stat(file) + isFolder := statErr == nil && fi.IsDir() + + ext, typ := getExtAndType(file, cfg.AllowedFileTypes) + // isInvalidFile (ts:114): no type, no ext, or a folder. + if typ == "" || ext == "" || isFolder { + res.ErrorFileTypes = append(res.ErrorFileTypes, file) + } + + // isFileSizeValid (ts:137): limit >= size. + if statErr == nil && cfg.FileSizeLimitInBytes < fi.Size() { + res.ErrorFileSizes = append(res.ErrorFileSizes, file) + } + + if IsFileSanitized(file) { + res.ErrorFileNames = append(res.ErrorFileNames, file) + } + + // isFileNameCharCountValid (ts:142): len(basename) <= limit. + if int64(len(filepath.Base(file))) > cfg.FileNameCharCount { + res.ErrorFileNamesCharCount = append(res.ErrorFileNamesCharCount, file) + } + + if original, ok := DoesImageHaveExistingSource(file); ok { + res.IntermediateImagesTotal++ + if existing, found := res.IntermediateImages[original]; found { + res.IntermediateImages[original] = existing + ", " + file + } else { + res.IntermediateImages[original] = file + } + } + } + return res +} + +// getExtAndType ports getExtAndType (ts:118): first allowed-type key +// whose `(?:\.)(<key>)$` regex (case-insensitive) matches wins. +func getExtAndType(filePath string, allowed map[string]string) (ext, typ string) { + for key, value := range allowed { + re, err := regexp.Compile(`(?i)(?:\.)(` + key + `)$`) + if err != nil { + continue + } + if m := re.FindStringSubmatch(filePath); m != nil { + return m[1], value + } + } + return "", "" +} + +// sanitizeSpacesRE — ts:648's / |(%20)|\+/g. +var sanitizeSpacesRE = regexp.MustCompile(`\x{00A0}|(%20)|\+`) + +// IsFileSanitized ports isFileSanitized (ts:641): the name is flagged +// when converting encoded/alternate whitespace to spaces changes it. +func IsFileSanitized(file string) bool { + filename := filepath.Base(file) + sanitized := sanitizeSpacesRE.ReplaceAllString(filename, " ") + return sanitized != filename +} + +// intermediateImageRE — ts:672's /([_-])?(\d+x\d+)(@\d+\w)?(\.\w{3,4})$/. +var intermediateImageRE = regexp.MustCompile(`([_-])?(\d+x\d+)(@\d+\w)?(\.\w{3,4})$`) + +// DoesImageHaveExistingSource ports doesImageHaveExistingSource (ts:677): +// when the filename looks like an intermediate image AND the original +// (sizing stripped) exists on disk, return the original's path. +func DoesImageHaveExistingSource(file string) (string, bool) { + filename := filepath.Base(file) + m := intermediateImageRE.FindString(filename) + if m == "" { + return "", false + } + extension := strings.TrimPrefix(filepath.Ext(filename), ".") + baseFileName := strings.Replace(filename, m, "", 1) + "." + extension + originalImage := filepath.Join(filepath.Dir(file), baseFileName) + if _, err := os.Stat(originalImage); err == nil { + return originalImage, true + } + return "", false +} diff --git a/internal/validatefiles/files_test.go b/internal/validatefiles/files_test.go new file mode 100644 index 000000000..033360c80 --- /dev/null +++ b/internal/validatefiles/files_test.go @@ -0,0 +1,143 @@ +package validatefiles + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsFileSanitized(t *testing.T) { + for name, want := range map[string]bool{ + "a+b.jpg": true, + "a%20b.jpg": true, + "a b.jpg": false, // plain space is fine + "a b.jpg": true, // no-break space + "clean.jpg": false, + } { + if got := IsFileSanitized(name); got != want { + t.Errorf("IsFileSanitized(%q) = %v, want %v", name, got, want) + } + } +} + +func TestDoesImageHaveExistingSource(t *testing.T) { + dir := t.TempDir() + mk := func(name string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return p + } + original := mk("panda.jpg") + intermediate := mk("panda-4000x6000.jpg") + orphan := mk("lonely-300x200.jpg") + retinaOriginal := mk("panda_test.jpg") + retina := mk("panda_test-4000x6000@2x.jpg") + _ = retinaOriginal + + if got, ok := DoesImageHaveExistingSource(intermediate); !ok || got != original { + t.Errorf("intermediate: got %q ok=%v", got, ok) + } + if _, ok := DoesImageHaveExistingSource(orphan); ok { + t.Error("orphan intermediate must not match (no original on disk)") + } + if got, ok := DoesImageHaveExistingSource(retina); !ok || !strings.HasSuffix(got, "panda_test.jpg") { + t.Errorf("retina: got %q ok=%v", got, ok) + } + if _, ok := DoesImageHaveExistingSource(original); ok { + t.Error("original is not an intermediate image") + } +} + +func TestValidateFiles(t *testing.T) { + dir := t.TempDir() + mk := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p + } + good := mk("good.jpg", "x") + badExt := mk("script.exe", "x") + tooBig := mk("big.jpg", strings.Repeat("x", 50)) + badName := mk("a+b.jpg", "x") + longName := mk(strings.Repeat("n", 30)+".jpg", "x") + original := mk("img.png", "x") + intermediate := mk("img-100x100.png", "x") + + cfg := Config{ + FileNameCharCount: 20, + FileSizeLimitInBytes: 40, + AllowedFileTypes: map[string]string{"jpg": "image/jpeg", "png": "image/png"}, + } + res := ValidateFiles([]string{good, badExt, tooBig, badName, longName, original, intermediate}, cfg) + + if len(res.ErrorFileTypes) != 1 || res.ErrorFileTypes[0] != badExt { + t.Errorf("ErrorFileTypes = %v", res.ErrorFileTypes) + } + if len(res.ErrorFileSizes) != 1 || res.ErrorFileSizes[0] != tooBig { + t.Errorf("ErrorFileSizes = %v", res.ErrorFileSizes) + } + if len(res.ErrorFileNames) != 1 || res.ErrorFileNames[0] != badName { + t.Errorf("ErrorFileNames = %v", res.ErrorFileNames) + } + if len(res.ErrorFileNamesCharCount) != 1 || res.ErrorFileNamesCharCount[0] != longName { + t.Errorf("ErrorFileNamesCharCount = %v", res.ErrorFileNamesCharCount) + } + if res.IntermediateImagesTotal != 1 || res.IntermediateImages[original] != intermediate { + t.Errorf("IntermediateImages = %v (total %d)", res.IntermediateImages, res.IntermediateImagesTotal) + } +} + +func TestSummaryLogsAllPass(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + SummaryLogs(&buf, SummaryParams{TotalFiles: 10, TotalFolders: 3}) + out := buf.String() + if strings.Contains(out, "ERROR") || strings.Contains(out, "RECOMMENDED") { + t.Errorf("all-pass summary contains failures: %q", out) + } + if strings.Count(out, "PASS") != 6 { + t.Errorf("want 6 PASS lines, got %d in %q", strings.Count(out, "PASS"), out) + } +} + +func TestSummaryLogsWithErrors(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + SummaryLogs(&buf, SummaryParams{ + FolderErrorsLength: 2, + FileTypeErrorsLength: 3, + TotalFiles: 10, + TotalFolders: 5, + }) + out := buf.String() + if !strings.Contains(out, "RECOMMENDED") || !strings.Contains(out, "2 folders, 5 folders total") { + t.Errorf("folder line wrong: %q", out) + } + if !strings.Contains(out, "3 invalid file extensions") { + t.Errorf("extension line wrong: %q", out) + } + // Node bug parity (ts:833): sizes line shows fileTypeErrorsLength. + if !strings.Contains(out, "3 invalid file sizes") { + t.Errorf("sizes line must reuse fileTypeErrorsLength (Node bug): %q", out) + } +} + +func TestLogErrorsInvalidNames(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + LogErrors(&buf, LogErrorsOptions{ + ErrorType: ErrInvalidNames, + InvalidFiles: []string{"a+b.jpg"}, + }) + out := buf.String() + if !strings.Contains(out, "Character validation: Invalid filename for file: ") || + !strings.Contains(out, "The following characters are allowed in file names:") { + t.Errorf("out = %q", out) + } +} diff --git a/internal/validatefiles/report.go b/internal/validatefiles/report.go new file mode 100644 index 000000000..338759774 --- /dev/null +++ b/internal/validatefiles/report.go @@ -0,0 +1,172 @@ +package validatefiles + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/fatih/color" +) + +// Error types — ValidateFilesErrors (ts:13). +const ( + ErrInvalidTypes = "invalid_types" + ErrIntermediateImages = "intermediate_images" + ErrInvalidSizes = "invalid_sizes" + ErrInvalidNames = "invalid_names" + ErrInvalidNameCharCounts = "invalid_name_character_counts" +) + +// acceptedCharacters — ts:159 (Set-deduplicated; the duplicate backtick +// in the Node literal collapses). +var acceptedCharacters = []string{ + "Non-English characters", "(", ")", "[", "]", "~", "&", "#", "%", "=", + "’", "'", "×", "@", "`", "?", "*", "!", "\"", "\\", "<", ">", ":", + ";", ",", "/", "$", "|", "{", "}", "spaces", +} + +// prohibitedCharacters — ts:196. +var prohibitedCharacters = []string{"+", "%20"} + +// recommendAcceptableFileTypes ports ts:225. +func recommendAcceptableFileTypes(w io.Writer, allowedFileTypes string) { + fmt.Fprintln(w, "Accepted file types: \n\n"+color.MagentaString(allowedFileTypes)) + fmt.Fprintln(w) +} + +// recommendAcceptableFileNames ports ts:231. +func recommendAcceptableFileNames(w io.Writer) { + allowed := strings.Join(acceptedCharacters, " ") + notAllowed := strings.Join(prohibitedCharacters, " ") + fmt.Fprintln(w, + "The following characters are allowed in file names:\n"+ + color.GreenString("All special characters, including: "+allowed+"\n\n")+ + "The following characters are prohibited in file names:\n"+ + color.RedString("Encoded or alternate whitespace, such as "+notAllowed+", are converted to proper spaces\n")) +} + +// LogErrorsOptions mirrors LogErrorOptions (ts:21). AllowedTypes feeds +// the invalid-types recommendation; Limit the size/char-count messages; +// IntermediateImages the duplicate-files detail. +type LogErrorsOptions struct { + ErrorType string + InvalidFiles []string + AllowedTypes []string + Limit int64 + IntermediateImages map[string]string +} + +// LogErrors ports logErrors (ts:709). +func LogErrors(w io.Writer, o LogErrorsOptions) { + if len(o.InvalidFiles) == 0 { + return + } + for _, file := range o.InvalidFiles { + switch o.ErrorType { + case ErrInvalidTypes: + fmt.Fprintln(w, color.RedString("✕"), "File extensions: Invalid file type for file: ", + color.CyanString(file)) + fmt.Fprintln(w) + recommendAcceptableFileTypes(w, strings.Join(o.AllowedTypes, ",")) + case ErrIntermediateImages: + fmt.Fprintln(w, color.RedString("✕"), + "Intermediate images: Duplicate files found:\n"+ + "Original file: "+color.BlueString(file+"\n")+ + "Intermediate images: "+color.CyanString(o.IntermediateImages[file]+"\n")) + case ErrInvalidSizes: + fmt.Fprintln(w, color.RedString("✕"), + fmt.Sprintf("File size cannot be more than %g GB", float64(o.Limit)/1024/1024/1024), + color.CyanString(file)) + fmt.Fprintln(w) + case ErrInvalidNameCharCounts: + fmt.Fprintln(w, color.RedString("✕"), + fmt.Sprintf("File name cannot have more than %d characters", o.Limit), + color.CyanString(file)) + case ErrInvalidNames: + fmt.Fprintln(w, color.RedString("✕"), "Character validation: Invalid filename for file: ", + color.CyanString(file)) + recommendAcceptableFileNames(w) + default: + fmt.Fprintln(w, color.RedString("✕"), "Unknown error type:", o.ErrorType) + } + } + fmt.Fprintln(w) +} + +// SortedKeys returns map keys sorted — Object.keys order in Node is +// insertion order, which a Go map can't reproduce; sorted keeps output +// deterministic for tests and humans. +func SortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// SummaryParams mirrors SummaryLogsParams (ts:766). +type SummaryParams struct { + FolderErrorsLength int + IntImagesErrorsLength int + FileTypeErrorsLength int + FileErrorFileSizesLength int + FilenameErrorsLength int + FileNameCharCountErrorsLength int + TotalFiles int + TotalFolders int +} + +// SummaryLogs ports summaryLogs (ts:777). Two Node copy bugs are kept +// deliberately: the sizes line prints fileTypeErrorsLength (ts:833) and +// the char-count line prints filenameErrorsLength (ts:862). +func SummaryLogs(w io.Writer, p SummaryParams) { + var messages []string + + if p.FolderErrorsLength > 0 { + messages = append(messages, color.New(color.BgYellow).Sprint(" RECOMMENDED ")+ + color.New(color.Bold, color.FgYellow).Sprintf(" %d folders, ", p.FolderErrorsLength)+ + fmt.Sprintf("%d folders total", p.TotalFolders)) + } else { + messages = append(messages, color.New(color.BgGreen).Sprint(" PASS ")+ + color.New(color.Bold, color.FgGreen).Sprintf(" %d folders, ", p.TotalFolders)+ + fmt.Sprintf("%d folders total", p.TotalFolders)) + } + + badge := func(bad bool) string { + if bad { + return color.New(color.FgWhite, color.BgRed).Sprint(" ERROR ") + } + return color.New(color.FgWhite, color.BgGreen).Sprint(" PASS ") + } + line := func(bad bool, detail string) string { + colored := color.GreenString(detail) + if bad { + colored = color.RedString(detail) + } + return badge(bad) + colored + fmt.Sprintf(", %d files total", p.TotalFiles) + } + + messages = append(messages, line(p.IntImagesErrorsLength > 0, + fmt.Sprintf(" %d intermediate images", p.IntImagesErrorsLength))) + messages = append(messages, line(p.FileTypeErrorsLength > 0, + fmt.Sprintf(" %d invalid file extensions", p.FileTypeErrorsLength))) + // Node bug (ts:833): prints fileTypeErrorsLength in the sizes line. + messages = append(messages, line(p.FileErrorFileSizesLength > 0, + fmt.Sprintf(" %d invalid file sizes", p.FileTypeErrorsLength))) + messages = append(messages, line(p.FilenameErrorsLength > 0, + fmt.Sprintf(" %d invalid filenames", p.FilenameErrorsLength))) + // Node bug (ts:862): prints filenameErrorsLength in the char-count line. + if p.FileNameCharCountErrorsLength > 0 { + messages = append(messages, badge(true)+ + color.RedString(fmt.Sprintf(" %d file names reached the maximum character count limit ", p.FilenameErrorsLength))+ + fmt.Sprintf(", %d files total", p.TotalFiles)) + } else { + messages = append(messages, color.New(color.BgGreen).Sprint(" PASS ")+ + color.GreenString(fmt.Sprintf(" %d file names reached the maximum character count limit", p.FilenameErrorsLength))+ + fmt.Sprintf(", %d files total", p.TotalFiles)) + } + + fmt.Fprintf(w, "\n%s\n\n", strings.Join(messages, "\n")) +} diff --git a/internal/validatefiles/validatefiles.go b/internal/validatefiles/validatefiles.go new file mode 100644 index 000000000..d7ad80afc --- /dev/null +++ b/internal/validatefiles/validatefiles.go @@ -0,0 +1,271 @@ +// Package validatefiles ports src/lib/vip-import-validate-files.ts (877 +// LOC): the local directory walk, WordPress folder-structure validation, +// per-file checks, and the error/summary reports printed by +// `vip import validate-files`. All output flows through injected +// io.Writers so the command wires stdout/stderr and tests capture +// buffers. +package validatefiles + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/fatih/color" +) + +// Config mirrors MediaImportConfig (graphqlTypes) as consumed by +// validateFiles (ts:50). +type Config struct { + FileNameCharCount int64 + FileSizeLimitInBytes int64 + AllowedFileTypes map[string]string // ext -> type label +} + +// WalkResult mirrors findNestedDirectories' return (ts:261). +type WalkResult struct { + Files []string + Folders []string // directories that directly contain files, in walk order +} + +// hiddenFileRE — ts:276's /(^|\/)\.[^/.]/. +var hiddenFileRE = regexp.MustCompile(`(^|/)\.[^/.]`) + +// FindNestedDirectories ports findNestedDirectories (ts:266): recursive +// walk collecting leaf files and the set of directories that directly +// contain files. Hidden entries are filtered. Read errors print the Node +// message to errW and return nil (ts:295-302). +func FindNestedDirectories(directory string, errW io.Writer) *WalkResult { + res := &WalkResult{} + seenFolder := map[string]bool{} + if !walkNested(directory, errW, res, seenFolder) { + return nil + } + return res +} + +func walkNested(directory string, errW io.Writer, res *WalkResult, seenFolder map[string]bool) bool { + entries, err := os.ReadDir(directory) + if err != nil { + fmt.Fprintln(errW, color.RedString("✕"), + fmt.Sprintf(" Error: Cannot read nested directory: %s. Reason: %s", directory, err.Error())) + return false + } + for _, entry := range entries { + if hiddenFileRE.MatchString(entry.Name()) { + continue + } + filePath := filepath.Join(directory, entry.Name()) + if entry.IsDir() { + // Node ignores the recursive call's failure (it only aborts + // the top-level call); mirror by continuing on sub-failure. + walkNested(filePath, errW, res, seenFolder) + continue + } + if !seenFolder[directory] { + seenFolder[directory] = true + res.Folders = append(res.Folders, directory) + } + res.Files = append(res.Files, filePath) + } + return true +} + +// indexPositions mirrors getIndexPositionOfFolders (ts:330). +type indexPositions struct { + uploadsIndex int // -1 when absent (Node indexOf semantics) + sitesIndex int + siteIDIndex int + yearIndex int + monthIndex int + hasSiteID bool + hasYear bool + hasMonth bool +} + +var ( + regexSiteID = regexp.MustCompile(`/sites/(\d+)`) + regexYear = regexp.MustCompile(`\b\d{4}\b`) + regexMonth = regexp.MustCompile(`\b\d{2}\b`) +) + +func getIndexPositionOfFolders(folderPath string, sites bool) indexPositions { + pos := indexPositions{uploadsIndex: -1, sitesIndex: -1, siteIDIndex: -1} + pathMutate := folderPath + directories := strings.Split(pathMutate, "/") + + pos.uploadsIndex = indexOf(directories, "uploads") + + if sites { + pos.sitesIndex = indexOf(directories, "sites") + if m := regexSiteID.FindStringSubmatch(pathMutate); m != nil { + pos.siteIDIndex = indexOf(directories, m[1]) + pos.hasSiteID = true + // ts:367 — strip the multisite segment so a 2-digit site ID + // isn't confused with the month. + pathMutate = strings.Replace(pathMutate, m[0], "", 1) + } + } + + if m := regexYear.FindString(pathMutate); m != "" { + pos.yearIndex = indexOf(directories, m) + pos.hasYear = true + } + if m := regexMonth.FindString(pathMutate); m != "" { + pos.monthIndex = indexOf(directories, m) + pos.hasMonth = true + } + return pos +} + +func indexOf(list []string, v string) int { + for i, s := range list { + if s == v { + return i + } + } + return -1 +} + +// singleSiteValidation ports singleSiteValidation (ts:428). Returns the +// folder path when it has structure errors, "" otherwise. +func singleSiteValidation(folderPath string, w io.Writer) string { + errs := 0 + fmt.Fprintln(w, color.New(color.Bold).Sprint("Folder:"), color.CyanString(folderPath)) + pos := getIndexPositionOfFolders(folderPath, false) + + if pos.uploadsIndex == 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "✅ File structure: Uploads directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`uploads`"), "directory") + errs++ + } + + // Node: `if (yearIndex && yearIndex === 1)` — index 0 would be falsy, + // but uploads occupies 0 in valid layouts so === 1 is the real gate. + if pos.hasYear && pos.yearIndex == 1 { + fmt.Fprintln(w, "✅ File structure: Year directory exists (format: YYYY)") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/YYYY`"), "directories") + errs++ + } + + if pos.hasMonth && pos.monthIndex == 2 { + fmt.Fprintln(w, "✅ File structure: Month directory exists (format: MM)") + fmt.Fprintln(w) + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/YYYY/MM`"), "directories") + fmt.Fprintln(w) + errs++ + } + + if errs > 0 { + return folderPath + } + return "" +} + +// multiSiteValidation ports multiSiteValidation (ts:504). +func multiSiteValidation(folderPath string, w io.Writer) string { + errs := 0 + fmt.Fprintln(w, color.New(color.Bold).Sprint("Folder:"), color.CyanString(folderPath)) + pos := getIndexPositionOfFolders(folderPath, true) + + if pos.uploadsIndex == 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "✅ File structure: Uploads directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`uploads`"), "directory") + errs++ + } + + if pos.sitesIndex == 1 { + fmt.Fprintln(w, "✅ File structure: Sites directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`sites`"), "directory") + errs++ + } + + if pos.hasSiteID && pos.siteIDIndex == 2 { + fmt.Fprintln(w, "✅ File structure: Site ID directory exists") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites/<siteID>`"), "directories") + errs++ + } + + if pos.hasYear && pos.yearIndex == 3 { + fmt.Fprintln(w, "✅ File structure: Year directory exists (format: YYYY)") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites/<siteID>/YYYY`"), "directories") + errs++ + } + + if pos.hasMonth && pos.monthIndex == 4 { + fmt.Fprintln(w, "✅ File structure: Month directory exists (format: MM)") + fmt.Fprintln(w) + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites/<siteID>/YYYY/MM`"), "directories") + fmt.Fprintln(w) + errs++ + } + + if errs > 0 { + return folderPath + } + return "" +} + +// FolderStructureValidation ports folderStructureValidation (ts:603): +// validate each folder (multisite when the path contains "sites"), +// returning the offending paths; prints the recommended-structure block +// when any folder failed. +func FolderStructureValidation(folders []string, w io.Writer) []string { + var allErrors []string + for _, folderPath := range folders { + var bad string + if strings.Contains(folderPath, "sites") { + bad = multiSiteValidation(folderPath, w) + } else { + bad = singleSiteValidation(folderPath, w) + } + if bad != "" { + allErrors = append(allErrors, bad) + } + } + if len(allErrors) > 0 { + recommendedFileStructure(w) + } + return allErrors +} + +// recommendedFileStructure ports recommendedFileStructure (ts:206). +func recommendedFileStructure(w io.Writer) { + underline := color.New(color.Underline) + fmt.Fprintln(w, + underline.Sprint("We recommend the WordPress default folder structure for your media files: \n\n")+ + underline.Sprint("Single sites:")+ + color.YellowString("`uploads/year/month/image.png`\n")+ + " e.g.-"+ + color.YellowString("`uploads/2020/06/image.png`\n")+ + underline.Sprint("Multisites:")+ + color.CyanString("`uploads/sites/siteID/year/month/image.png`\n")+ + " e.g.-"+ + color.CyanString("`uploads/sites/5/2020/06/images.png`\n")) + fmt.Fprintln(w, "------------------------------------------------------------") + fmt.Fprintln(w) +} diff --git a/internal/validatefiles/validatefiles_test.go b/internal/validatefiles/validatefiles_test.go new file mode 100644 index 000000000..3607ac224 --- /dev/null +++ b/internal/validatefiles/validatefiles_test.go @@ -0,0 +1,127 @@ +package validatefiles + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func mkTree(t *testing.T, root string, files []string) { + t.Helper() + for _, f := range files { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestFindNestedDirectories(t *testing.T) { + root := t.TempDir() + mkTree(t, root, []string{ + "uploads/2020/06/a.jpg", + "uploads/2020/06/b.png", + "uploads/2020/06/.DS_Store", // hidden — filtered (ts:276) + "uploads/2020/07/c.gif", + }) + res := FindNestedDirectories(filepath.Join(root, "uploads"), &bytes.Buffer{}) + if res == nil { + t.Fatal("walk failed") + } + if len(res.Files) != 3 { + t.Errorf("files = %v", res.Files) + } + if len(res.Folders) != 2 { + t.Errorf("folders = %v", res.Folders) + } + for _, f := range res.Files { + if strings.Contains(f, ".DS_Store") { + t.Errorf("hidden file leaked: %s", f) + } + } +} + +func TestFindNestedDirectoriesUnreadable(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root ignores permissions") + } + root := t.TempDir() + locked := filepath.Join(root, "locked") + if err := os.MkdirAll(locked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + var errBuf bytes.Buffer + res := FindNestedDirectories(locked, &errBuf) + if res != nil { + t.Error("unreadable top-level dir must return nil") + } + if !strings.Contains(errBuf.String(), "Error: Cannot read nested directory: "+locked) { + t.Errorf("errW = %q", errBuf.String()) + } +} + +func TestFolderStructureValidationSingleSiteGood(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"uploads/2020/06"}, &buf) + if len(bad) != 0 { + t.Errorf("bad = %v\n%s", bad, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "✅ File structure: Uploads directory exists", + "✅ File structure: Year directory exists (format: YYYY)", + "✅ File structure: Month directory exists (format: MM)", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} + +func TestFolderStructureValidationSingleSiteBad(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"media/stuff"}, &buf) + if len(bad) != 1 || bad[0] != "media/stuff" { + t.Errorf("bad = %v", bad) + } + out := buf.String() + if !strings.Contains(out, "Recommended: Media files should reside in an `uploads` directory") { + t.Errorf("missing uploads recommendation: %q", out) + } + if !strings.Contains(out, "We recommend the WordPress default folder structure") { + t.Errorf("missing recommended-structure block: %q", out) + } +} + +func TestFolderStructureValidationMultisiteGood(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"uploads/sites/5/2020/06"}, &buf) + if len(bad) != 0 { + t.Errorf("bad = %v\n%s", bad, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "✅ File structure: Uploads directory exists", + "✅ File structure: Sites directory exists", + "✅ File structure: Site ID directory exists", + "✅ File structure: Year directory exists (format: YYYY)", + "✅ File structure: Month directory exists (format: MM)", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 000000000..0102a3fdb --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,14 @@ +// Package version exposes the binary version metadata. +// Values are injected via -ldflags at build time (see Makefile). +package version + +import "fmt" + +var ( + Version = "dev" + Commit = "unknown" +) + +func String() string { + return fmt.Sprintf("vip-next %s (commit %s)", Version, Commit) +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 000000000..8d1093a25 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,25 @@ +package version + +import "testing" + +func TestStringIncludesVersionAndCommit(t *testing.T) { + Version = "1.2.3" + Commit = "abcdef0" + + got := String() + want := "vip-next 1.2.3 (commit abcdef0)" + if got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} + +func TestStringDefaultWhenUnset(t *testing.T) { + Version = "dev" + Commit = "unknown" + + got := String() + want := "vip-next dev (commit unknown)" + if got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} diff --git a/internal/wpshell/parser.go b/internal/wpshell/parser.go new file mode 100644 index 000000000..c6a87e89b --- /dev/null +++ b/internal/wpshell/parser.go @@ -0,0 +1,68 @@ +// Package wpshell ports the WP-CLI subshell from src/bin/vip-wp.js and +// the DFA command parser from src/lib/wp/helpers.ts. The parser +// accumulates a full WP-CLI command across physical input lines, +// preserving quoted multiline values without shell unescaping. +package wpshell + +type state int + +const ( + s0 state = iota // normal + s1 // after backslash + s2 // inside double quotes + s3 // after backslash inside double quotes + s4 // inside single quotes + ff // final +) + +// CmdState mirrors helpers.ts CmdState. +type CmdState struct { + state state + Command string + Done bool +} + +func NewCmdState() *CmdState { st := &CmdState{}; ResetState(st); return st } + +func ResetState(st *CmdState) { st.state = s0; st.Command = ""; st.Done = false } + +// stateTable: rows = current state, cols = char class [\ " ' \n other]. +// helpers.ts:78. +var stateTable = [6][5]state{ + /* s0 */ {s1, s2, s4, ff, s0}, + /* s1 */ {s0, s0, s0, ff, s0}, + /* s2 */ {s3, s0, s2, s2, s2}, + /* s3 */ {s2, s2, s2, s2, s2}, + /* s4 */ {s4, s4, s0, s4, s4}, + /* ff */ {ff, ff, ff, ff, ff}, +} + +func charClass(r rune) int { + switch r { + case '\\': + return 0 + case '"': + return 1 + case '\'': + return 2 + case '\n': + return 3 + default: + return 4 + } +} + +// StateMachine ports stateMachine (helpers.ts:120): appends a newline to +// the line, then walks each rune. Reaching ff sets Done and stops +// (the terminating newline is NOT appended to Command). +func StateMachine(st *CmdState, line string) { + line += "\n" + for _, r := range line { + st.state = stateTable[st.state][charClass(r)] + if st.state == ff { + st.Done = true + return + } + st.Command += string(r) + } +} diff --git a/internal/wpshell/parser_test.go b/internal/wpshell/parser_test.go new file mode 100644 index 000000000..cbc2de911 --- /dev/null +++ b/internal/wpshell/parser_test.go @@ -0,0 +1,58 @@ +package wpshell + +import "testing" + +func TestStateMachineSingleLine(t *testing.T) { + st := NewCmdState() + StateMachine(st, "wp option get home") + if !st.Done { + t.Fatal("single line should finalize on the trailing newline") + } + if st.Command != "wp option get home" { + t.Errorf("command = %q", st.Command) + } +} + +func TestStateMachineMultilineQuoted(t *testing.T) { + st := NewCmdState() + StateMachine(st, `wp option set mykey "first line`) + if st.Done { + t.Fatal("open double-quote must not finalize") + } + StateMachine(st, `second line"`) + if !st.Done { + t.Fatal("closing quote + newline finalizes") + } + if st.Command != "wp option set mykey \"first line\nsecond line\"" { + t.Errorf("command = %q", st.Command) + } +} + +func TestStateMachineSingleQuotes(t *testing.T) { + st := NewCmdState() + StateMachine(st, `wp eval 'return "x";'`) + if !st.Done || st.Command != `wp eval 'return "x";'` { + t.Errorf("done=%v command=%q", st.Done, st.Command) + } +} + +func TestStateMachineBackslashNotContinuation(t *testing.T) { + // helpers.ts: a backslash before newline is NOT a line continuation. + st := NewCmdState() + StateMachine(st, `wp post list \`) + if !st.Done { + t.Fatalf("backslash-at-eol still terminates (done=%v)", st.Done) + } + if st.Command != `wp post list \` { + t.Errorf("command = %q", st.Command) + } +} + +func TestResetState(t *testing.T) { + st := NewCmdState() + StateMachine(st, "wp x") + ResetState(st) + if st.Done || st.Command != "" { + t.Errorf("reset failed: %+v", st) + } +} diff --git a/internal/wpshell/repl.go b/internal/wpshell/repl.go new file mode 100644 index 000000000..6caa5cccf --- /dev/null +++ b/internal/wpshell/repl.go @@ -0,0 +1,81 @@ +package wpshell + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// REPL drives the interactive WP-CLI subshell. Run is invoked with each +// finalized command (leading "wp " stripped, matching vip-wp.js:493). +// Serve returns when input reaches EOF or the user types `exit`. +type REPL struct { + Prompt string + Run func(command string) error +} + +// Serve reads lines until EOF / exit. Port of the readline 'line' handler +// (vip-wp.js:445). Non-`wp` first input is rejected; `wp ...` commands are +// accumulated via the DFA across lines. +func (r *REPL) Serve(in *bufio.Reader, out io.Writer) error { + state := NewCmdState() + seenWP := false + + fmt.Fprint(out, r.Prompt) + for { + line, err := in.ReadString('\n') + line = strings.TrimRight(line, "\n") + atEOF := err == io.EOF + + if !atEOF || line != "" { + if r.handleLine(out, state, &seenWP, line) == exitREPL { + return nil + } + } + if atEOF { + return nil + } + } +} + +type lineResult int + +const ( + continueREPL lineResult = iota + exitREPL +) + +func (r *REPL) handleLine(out io.Writer, state *CmdState, seenWP *bool, line string) lineResult { + // Blank line re-prompts (vip-wp.js:451). + if line == "" { + fmt.Fprint(out, r.Prompt) + return continueREPL + } + // exit / exit; quits when not mid-command (vip-wp.js:457). + if !*seenWP && strings.HasPrefix(line, "exit") { + return exitREPL + } + if !*seenWP && strings.HasPrefix(strings.TrimLeft(line, " \t"), "wp ") { + *seenWP = true + ResetState(state) + } + if !*seenWP { + ResetState(state) + fmt.Fprintln(out, "Error: invalid command, please pass a valid WP-CLI command.") + fmt.Fprint(out, r.Prompt) + return continueREPL + } + + StateMachine(state, line) + if !state.Done { + return continueREPL // keep accumulating (multiline quote) + } + + cmd := strings.TrimPrefix(state.Command, "wp ") + *seenWP = false + ResetState(state) + _ = r.Run(cmd) + fmt.Fprint(out, r.Prompt) + return continueREPL +} diff --git a/internal/wpshell/repl_test.go b/internal/wpshell/repl_test.go new file mode 100644 index 000000000..fa7883336 --- /dev/null +++ b/internal/wpshell/repl_test.go @@ -0,0 +1,71 @@ +package wpshell + +import ( + "bufio" + "strings" + "testing" +) + +func TestREPLRunsValidCommand(t *testing.T) { + var ran []string + in := strings.NewReader("wp option get home\nexit\n") + var out strings.Builder + loop := &REPL{ + Prompt: "app.develop:~$ ", + Run: func(cmd string) error { ran = append(ran, cmd); return nil }, + } + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if len(ran) != 1 || ran[0] != "option get home" { + t.Errorf("ran = %v (leading 'wp ' must be stripped)", ran) + } +} + +func TestREPLInvalidCommand(t *testing.T) { + in := strings.NewReader("ls -la\nexit\n") + var out strings.Builder + loop := &REPL{Run: func(string) error { t.Fatal("must not run"); return nil }} + _ = loop.Serve(bufio.NewReader(in), &out) + if !strings.Contains(out.String(), "invalid command, please pass a valid WP-CLI command.") { + t.Errorf("out = %q", out.String()) + } +} + +func TestREPLExit(t *testing.T) { + in := strings.NewReader("exit\n") + var out strings.Builder + ran := false + loop := &REPL{Run: func(string) error { ran = true; return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if ran { + t.Error("exit must not run a command") + } +} + +func TestREPLMultilineCommand(t *testing.T) { + var ran []string + in := strings.NewReader("wp option set k \"line1\nline2\"\nexit\n") + var out strings.Builder + loop := &REPL{Run: func(cmd string) error { ran = append(ran, cmd); return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if len(ran) != 1 || ran[0] != "option set k \"line1\nline2\"" { + t.Errorf("ran = %v", ran) + } +} + +func TestREPLBlankLineReprompts(t *testing.T) { + in := strings.NewReader("\n\nexit\n") + var out strings.Builder + loop := &REPL{Prompt: "P$ ", Run: func(string) error { return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if strings.Count(out.String(), "P$ ") < 2 { + t.Errorf("expected multiple prompts, out = %q", out.String()) + } +} diff --git a/internal/wpshell/requote.go b/internal/wpshell/requote.go new file mode 100644 index 000000000..a4d5db518 --- /dev/null +++ b/internal/wpshell/requote.go @@ -0,0 +1,13 @@ +package wpshell + +import "strings" + +// RequoteArgs ports requoteArgs (format.ts:135): wrap each arg in double +// quotes, escaping any inner double quotes. +func RequoteArgs(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = `"` + strings.ReplaceAll(a, `"`, `\"`) + `"` + } + return out +} diff --git a/internal/wpshell/requote_test.go b/internal/wpshell/requote_test.go new file mode 100644 index 000000000..8767a3a87 --- /dev/null +++ b/internal/wpshell/requote_test.go @@ -0,0 +1,15 @@ +package wpshell + +import ( + "slices" + "testing" +) + +func TestRequoteArgs(t *testing.T) { + // format.ts:135 — wrap each arg in double quotes, escaping inner ". + got := RequoteArgs([]string{"post", "list", `--search=a "b" c`}) + want := []string{`"post"`, `"list"`, `"--search=a \"b\" c"`} + if !slices.Equal(got, want) { + t.Errorf("got %v want %v", got, want) + } +} diff --git a/internal/wpssh/wpssh.go b/internal/wpssh/wpssh.go new file mode 100644 index 000000000..b9bb61762 --- /dev/null +++ b/internal/wpssh/wpssh.go @@ -0,0 +1,117 @@ +// Package wpssh ports the SSH WP-CLI execution strategy from +// src/commands/wp-ssh.ts (executeCommandOverSSH, lines 170-258). +// Signal handling is intentionally omitted — that belongs to the command +// layer which owns os.Signal channels. +package wpssh + +import ( + "context" + "errors" + "fmt" + "io" + "net" + + "golang.org/x/crypto/ssh" +) + +// SSH_HANDSHAKE_TIMEOUT_MS matches the Node constant (wp-ssh.ts:22). +const handshakeTimeout = 5e9 // 5 seconds in nanoseconds (time.Duration) + +// Auth carries SSH credentials + command identifiers from the +// TriggerWPCLICommand mutation. Port is a string (schema: String!). +type Auth struct { + Host, Port, Username string + PrivateKey string + Passphrase string + GUID, InputToken string +} + +// Streams injects process stdio (real os.Stdin/out in production). +type Streams struct { + Stdin io.Reader + Stdout, Stderr io.Writer +} + +// Meta carries terminal dimensions + CLI version for the exec preamble. +type Meta struct { + Version string + Rows int + Columns int + TTY bool +} + +// ExitCodeError signals a non-zero remote exit (wp-ssh.ts:59 NonZeroExitCodeError). +type ExitCodeError struct { + Code int + GUID string +} + +func (e *ExitCodeError) Error() string { + return fmt.Sprintf("command failed with exit code %d", e.Code) +} + +// Run connects to the SSH server described by auth, execs the env-var preamble, +// pipes stdio for the duration, and returns any error. +// It is the port of executeCommandOverSSH (wp-ssh.ts:170-258). +func Run(ctx context.Context, auth Auth, streams Streams, meta Meta) error { + signer, err := parseSigner(auth.PrivateKey, auth.Passphrase) + if err != nil { + return fmt.Errorf("wpssh: parse private key: %w", err) + } + + cfg := &ssh.ClientConfig{ + User: auth.Username, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + // Node's ssh2 does not verify host keys; the endpoint and credentials + // originate from the authenticated VIP API, so we replicate that + // behaviour here for Node parity. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // #nosec G106 + Timeout: handshakeTimeout, + } + + addr := net.JoinHostPort(auth.Host, auth.Port) + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return fmt.Errorf("wpssh: dial %s: %w", addr, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("wpssh: new session: %w", err) + } + defer session.Close() + + session.Stdin = streams.Stdin + session.Stdout = streams.Stdout + session.Stderr = streams.Stderr + + // Build the env-var preamble exactly as wp-ssh.ts:199 does. + ttyStr := "false" + if meta.TTY { + ttyStr = "true" + } + cmd := fmt.Sprintf( + "GUID=%s INPUT_TOKEN=%s VERSION=%s ROWS=%d COLUMNS=%d TTY=%s", + auth.GUID, auth.InputToken, meta.Version, meta.Rows, meta.Columns, ttyStr, + ) + + if err := session.Run(cmd); err != nil { + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + return &ExitCodeError{Code: exitErr.ExitStatus(), GUID: auth.GUID} + } + return fmt.Errorf("wpssh: run: %w", err) + } + return nil +} + +// parseSigner parses an OpenSSH PEM private key, optionally decrypted with +// passphrase (wp-ssh.ts connect options: privateKey + passphrase). +func parseSigner(privateKeyPEM, passphrase string) (ssh.Signer, error) { + keyBytes := []byte(privateKeyPEM) + if passphrase != "" { + return ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(passphrase)) + } + return ssh.ParsePrivateKey(keyBytes) +} diff --git a/internal/wpssh/wpssh_test.go b/internal/wpssh/wpssh_test.go new file mode 100644 index 000000000..f00ef5e5e --- /dev/null +++ b/internal/wpssh/wpssh_test.go @@ -0,0 +1,203 @@ +package wpssh_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "strings" + "testing" + + gossh "golang.org/x/crypto/ssh" + + "github.com/Automattic/vip/internal/wpssh" +) + +// testClientKeyPEM generates a fresh ed25519 private key and returns it as +// an OpenSSH PEM string (the format ssh.ParsePrivateKey accepts). +func testClientKeyPEM(t *testing.T) string { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ed25519 key: %v", err) + } + block, err := gossh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal private key: %v", err) + } + return string(pem.EncodeToMemory(block)) +} + +// startEchoSSHServer stands up an in-process SSH server on a random local +// port. On each session it: +// 1. Accepts an "exec" channel request. +// 2. Writes the exec command string to the channel stdout so the test can +// assert the preamble. +// 3. Sends an exit-status reply with exitCode. +// +// Returns host and port strings; t.Cleanup closes the listener. +func startEchoSSHServer(t *testing.T, exitCode int) (host, port string) { + t.Helper() + + // Generate a host key. + _, hostPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + hostSigner, err := gossh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatalf("new host signer: %v", err) + } + + cfg := &gossh.ServerConfig{ + NoClientAuth: true, + } + cfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + addr := ln.Addr().String() + host, port, err = net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host/port: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go handleSSHConn(conn, cfg, exitCode) + } + }() + + return host, port +} + +func handleSSHConn(conn net.Conn, cfg *gossh.ServerConfig, exitCode int) { + srvConn, chans, reqs, err := gossh.NewServerConn(conn, cfg) + if err != nil { + return + } + defer srvConn.Close() + go gossh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(gossh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + return + } + go handleSession(ch, requests, exitCode) + } +} + +// execPayload is the wire format for an "exec" request payload. +type execPayload struct { + Command string +} + +func handleSession(ch gossh.Channel, requests <-chan *gossh.Request, exitCode int) { + defer ch.Close() + + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + _ = req.Reply(false, nil) + } + continue + } + + // Decode the length-prefixed command string. + var payload execPayload + if err := gossh.Unmarshal(req.Payload, &payload); err != nil { + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + + if req.WantReply { + _ = req.Reply(true, nil) + } + + // Echo the command string to stdout so the test can assert the preamble. + _, _ = fmt.Fprint(ch, payload.Command) + + // Send exit-status before closing. + exitMsg := gossh.Marshal(struct{ Code uint32 }{uint32(exitCode)}) + _, _ = ch.SendRequest("exit-status", false, exitMsg) + return + } +} + +// --- Tests ------------------------------------------------------------------ + +func TestRunSSHHappyPath(t *testing.T) { + host, port := startEchoSSHServer(t, 0) + var stdout bytes.Buffer + // Use separate writers for stdout and stderr: x/crypto/ssh copies them + // concurrently, and sharing a single bytes.Buffer would race. + err := wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), + GUID: "g1", InputToken: "tok", + }, wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100, TTY: false}) + if err != nil { + t.Fatal(err) + } + // The exec command string carries the env-var preamble (wp-ssh.ts:199). + if !strings.Contains(stdout.String(), "GUID=g1") || + !strings.Contains(stdout.String(), "INPUT_TOKEN=tok") || + !strings.Contains(stdout.String(), "VERSION=test") { + t.Errorf("exec line = %q", stdout.String()) + } +} + +func TestRunSSHNonZeroExit(t *testing.T) { + host, port := startEchoSSHServer(t, 3) + var stdout bytes.Buffer + err := wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), GUID: "g", InputToken: "t", + }, + wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100}) + var ec *wpssh.ExitCodeError + if !errors.As(err, &ec) || ec.Code != 3 { + t.Fatalf("err = %v, want exit-code 3", err) + } +} + +func TestRunSSHRefusedPort(t *testing.T) { + // Find a port that's definitely not listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() // close immediately so the port is refused + + host, port, _ := net.SplitHostPort(addr) + var stdout bytes.Buffer + err = wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), + }, + wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100}) + if err == nil { + t.Fatal("expected error connecting to closed port, got nil") + } +} diff --git a/internal/wpstream/e2e_test.go b/internal/wpstream/e2e_test.go new file mode 100644 index 000000000..7270b1634 --- /dev/null +++ b/internal/wpstream/e2e_test.go @@ -0,0 +1,178 @@ +//go:build wpstream_e2e + +package wpstream + +import ( + "bufio" + "bytes" + "context" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// startFixtureServer spawns the Node socket.io fixture on a free port and waits +// for "LISTENING". Requires Node 22. Env knobs configure behavior. +func startFixtureServer(t *testing.T, env map[string]string) (apiHost string) { + t.Helper() + port := freePort(t) + cmd := exec.Command("node", "internal/wpstream/testdata/fixture-server.js") + cmd.Dir = repoRoot(t) // module root (dir containing go.mod) + cmd.Env = append(envSlice(env), "PORT="+strconv.Itoa(port)) + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + t.Skipf("node not available: %v", err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + sc := bufio.NewScanner(stdout) + ready := make(chan struct{}) + go func() { + for sc.Scan() { + if strings.Contains(sc.Text(), "LISTENING") { + close(ready) + return + } + } + }() + select { + case <-ready: + case <-time.After(10 * time.Second): + t.Fatal("fixture server did not start") + } + return "http://127.0.0.1:" + strconv.Itoa(port) +} + +// freePort finds an available TCP port on localhost. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freePort: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +// repoRoot walks up from the test's working dir until it finds a directory +// containing go.mod (the module root). +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("repoRoot: Getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("repoRoot: go.mod not found") + } + dir = parent + } +} + +// envSlice starts from os.Environ() and appends k=v for each map entry. +func envSlice(env map[string]string) []string { + base := os.Environ() + for k, v := range env { + base = append(base, k+"="+v) + } + return base +} + +func TestE2EHappyPathStdoutAndExit(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "hello from wp-cli\n", + "EXIT_CODE": "0", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{ + APIHost: apiHost, Token: "test-token", + GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if !strings.Contains(out.String(), "hello from wp-cli") { + t.Errorf("stdout = %q", out.String()) + } +} + +func TestE2ENonZeroExit(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "boom\n", "EXIT_CODE": "3", "EXIT_MESSAGE": "failed", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 3 { + t.Errorf("exit = %d, want 3", res.ExitCode) + } +} + +func TestE2EOffsetResumeAfterKill(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "0123456789ABCDEF", "KILL_AFTER": "8", "EXIT_CODE": "0", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if out.String() != "0123456789ABCDEF" { + t.Errorf("resumed stdout = %q, want full payload once", out.String()) + } +} + +// TestE2EOffsetResumeMultipleKills verifies that the reconnect loop handles +// MORE THAN ONE disconnect correctly (C1 fix). The fixture kills the connection +// twice: after 8 bytes on the first attempt (offset=0→8), and after 8 bytes +// on the second attempt (offset=8→16). The third attempt delivers the remaining +// 8 bytes (offset=16→24) and emits exit 0. The full 24-byte payload must appear +// in Stdout exactly once. +func TestE2EOffsetResumeMultipleKills(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "0123456789ABCDEFGHIJKLMN", + "KILL_AFTER": "8", + "KILL_TIMES": "2", + "EXIT_CODE": "0", + }) + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + res, err := Run(ctx, Options{ + APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0", res.ExitCode) + } + const want = "0123456789ABCDEFGHIJKLMN" + if out.String() != want { + t.Errorf("stdout = %q, want %q", out.String(), want) + } +} diff --git a/internal/wpstream/engineio.go b/internal/wpstream/engineio.go new file mode 100644 index 000000000..ec7981121 --- /dev/null +++ b/internal/wpstream/engineio.go @@ -0,0 +1,291 @@ +// Package wpstream is a hand-rolled Engine.IO v4 + Socket.IO v4 + +// socket.io-stream client. It ports the socket.io transport used by +// src/bin/vip-wp.js for the wpcliStrategy=websocket WP-CLI strategy. +// +// engineio.go is the bottom layer: the Engine.IO v4 transport. It does the +// HTTP long-poll handshake, upgrades to WebSocket, answers server pings, and +// exposes a packet-level duplex to the Socket.IO codec above it. +package wpstream + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Engine.IO v4 packet type chars (engine.io-parser commons.js). +const ( + eioOpen = '0' + eioClose = '1' + eioPing = '2' + eioPong = '3' + eioMessage = '4' + eioUpgrade = '5' + eioNoop = '6' +) + +// Packet is one Engine.IO packet. For text packets Type is the type char and +// Data is the payload after it. For raw binary frames (socket.io attachments) +// Binary is true, Type is eioMessage by convention, and Data holds the bytes. +type Packet struct { + Type byte + Data []byte + Binary bool +} + +// DialOptions configure the Engine.IO connection. +type DialOptions struct { + BaseURL string // e.g. https://api.wpvip.com (no trailing /socket.io/) + Header http.Header // extraHeaders carried on BOTH transports (Bearer token) + Client *http.Client +} + +type openPacket struct { + SID string `json:"sid"` + Upgrades []string `json:"upgrades"` + PingInterval int `json:"pingInterval"` + PingTimeout int `json:"pingTimeout"` +} + +// Engine is a connected Engine.IO transport (after the websocket upgrade). +// A background read loop dispatches incoming frames: pings are answered +// immediately, non-ping packets are queued on recvCh for Read callers. +type Engine struct { + ws *websocket.Conn + sid string + pingInterval time.Duration + pingTimeout time.Duration + + recvCh chan Packet + errCh chan error // closed / first error from the read loop + + mu sync.Mutex + closeOnce sync.Once + closed chan struct{} + readCancel context.CancelFunc // cancels the readLoop context (I1) +} + +func (e *Engine) SID() string { return e.sid } + +// Dial performs the polling handshake then upgrades to WebSocket. +func Dial(ctx context.Context, opts DialOptions) (*Engine, error) { + client := opts.Client + if client == nil { + // vip-wp.js:539 passes createProxyAgent(API_HOST) to the socket.io + // client, so this transport is proxied on Node too — but by Node's + // policy, not http.DefaultTransport's. See internal/httpproxy. + client = httpproxy.Client() + } + + // 1. Polling handshake: GET /socket.io/?EIO=4&transport=polling + open, err := pollingHandshake(ctx, client, opts) + if err != nil { + return nil, err + } + + // 2. WebSocket upgrade with sid. + wsURL := buildURL(opts.BaseURL, "websocket", open.SID) + c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ + HTTPClient: client, + HTTPHeader: opts.Header, + }) + if err != nil { + return nil, fmt.Errorf("wpstream: websocket dial: %w", err) + } + c.SetReadLimit(-1) // server controls payload size; no client cap + + // 3. Probe: send "2probe", expect "3probe", send "5". + if err := c.Write(ctx, websocket.MessageText, []byte("2probe")); err != nil { + return nil, err + } + _, resp, err := c.Read(ctx) + if err != nil || string(resp) != "3probe" { + return nil, fmt.Errorf("wpstream: bad probe reply %q (%v)", resp, err) + } + if err := c.Write(ctx, websocket.MessageText, []byte{eioUpgrade}); err != nil { + return nil, err + } + + // I1: derive a cancelable context so Close() can stop readLoop. + readCtx, readCancel := context.WithCancel(context.Background()) + + eng := &Engine{ + ws: c, + sid: open.SID, + pingInterval: time.Duration(open.PingInterval) * time.Millisecond, + pingTimeout: time.Duration(open.PingTimeout) * time.Millisecond, + recvCh: make(chan Packet, 64), + errCh: make(chan error, 1), + closed: make(chan struct{}), + readCancel: readCancel, + } + go eng.readLoop(readCtx) + return eng, nil +} + +// readLoop is the single goroutine that owns the websocket read side. +// It answers server pings immediately (serialized through the write mutex) +// and queues all other packets onto recvCh. +// I1: ctx is derived from a cancelable context created in Dial; Close() cancels +// it, which unblocks e.ws.Read and terminates the goroutine cleanly. +func (e *Engine) readLoop(ctx context.Context) { + defer close(e.errCh) + for { + typ, data, err := e.ws.Read(ctx) + if err != nil { + select { + case e.errCh <- err: + default: + } + return + } + if typ == websocket.MessageBinary { + select { + case e.recvCh <- Packet{Type: eioMessage, Data: data, Binary: true}: + case <-e.closed: + return + } + continue + } + if len(data) == 0 { + continue + } + switch data[0] { + case eioPing: + // Server-initiated heartbeat: reply with pong immediately. + _ = e.write(context.Background(), websocket.MessageText, []byte{eioPong}) + case eioNoop: + // skip + case eioClose: + select { + case e.errCh <- io.EOF: + default: + } + return + default: + select { + case e.recvCh <- Packet{Type: data[0], Data: data[1:]}: + case <-e.closed: + return + } + } + } +} + +func pollingHandshake(ctx context.Context, client *http.Client, opts DialOptions) (*openPacket, error) { + u := buildURL(opts.BaseURL, "polling", "") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + for k, vs := range opts.Header { + for _, v := range vs { + req.Header.Add(k, v) + } + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("wpstream: polling handshake: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + // First packet of the (possibly \x1e-joined) payload is the open packet. + first := body + if i := strings.IndexByte(string(body), '\x1e'); i >= 0 { + first = body[:i] + } + if len(first) == 0 || first[0] != eioOpen { + return nil, fmt.Errorf("wpstream: expected open packet, got %q", first) + } + var op openPacket + if err := json.Unmarshal(first[1:], &op); err != nil { + return nil, fmt.Errorf("wpstream: parse open packet: %w", err) + } + return &op, nil +} + +// buildURL constructs <base>/socket.io/?EIO=4&transport=<t>[&sid=<sid>] +// with ws/wss scheme for the websocket transport. +func buildURL(base, transport, sid string) string { + u, _ := url.Parse(base) + u.Path = strings.TrimRight(u.Path, "/") + "/socket.io/" + if transport == "websocket" { + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + } + } + q := u.Query() + q.Set("EIO", "4") + q.Set("transport", transport) + if sid != "" { + q.Set("sid", sid) + } + u.RawQuery = q.Encode() + return u.String() +} + +// Read returns the next non-heartbeat packet. Blocks until a packet arrives, +// the context is cancelled, or the connection is closed. +func (e *Engine) Read(ctx context.Context) (Packet, error) { + select { + case pkt, ok := <-e.recvCh: + if !ok { + return Packet{}, errClosed + } + return pkt, nil + case err, ok := <-e.errCh: + if !ok { + return Packet{}, errClosed + } + return Packet{}, err + case <-ctx.Done(): + return Packet{}, ctx.Err() + case <-e.closed: + return Packet{}, errClosed + } +} + +// WriteMessage sends a Socket.IO message packet ("4" + payload). +func (e *Engine) WriteMessage(ctx context.Context, payload []byte) error { + frame := append([]byte{eioMessage}, payload...) + return e.write(ctx, websocket.MessageText, frame) +} + +// WriteBinary sends a raw binary attachment frame (EIO4: no type prefix). +func (e *Engine) WriteBinary(ctx context.Context, data []byte) error { + return e.write(ctx, websocket.MessageBinary, data) +} + +func (e *Engine) write(ctx context.Context, typ websocket.MessageType, data []byte) error { + e.mu.Lock() + defer e.mu.Unlock() + return e.ws.Write(ctx, typ, data) +} + +func (e *Engine) Close() error { + e.closeOnce.Do(func() { + // I1: cancel the readLoop context so ws.Read unblocks immediately. + e.readCancel() + close(e.closed) + }) + return e.ws.Close(websocket.StatusNormalClosure, "") +} + +var errClosed = errors.New("wpstream: engine closed") diff --git a/internal/wpstream/engineio_test.go b/internal/wpstream/engineio_test.go new file mode 100644 index 000000000..e383c8186 --- /dev/null +++ b/internal/wpstream/engineio_test.go @@ -0,0 +1,82 @@ +package wpstream + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" +) + +// fakeEIOServer serves the EIO4 polling handshake then accepts a websocket +// upgrade, completes the 2probe/5 dance, and sends one message packet. +func fakeEIOServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/socket.io/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("transport") == "polling" { + // Open packet: type '0' + JSON handshake. + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = w.Write([]byte(`0{"sid":"abc","upgrades":["websocket"],"pingInterval":300,"pingTimeout":200,"maxPayload":1000000}`)) + return + } + // websocket transport + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer c.Close(websocket.StatusNormalClosure, "") + ctx := r.Context() + // Expect "2probe", reply "3probe". + _, probe, _ := c.Read(ctx) + if string(probe) != "2probe" { + t.Errorf("probe = %q", probe) + } + _ = c.Write(ctx, websocket.MessageText, []byte("3probe")) + // Expect "5" (upgrade). + _, up, _ := c.Read(ctx) + if string(up) != "5" { + t.Errorf("upgrade = %q", up) + } + // Send one message packet "4hello". + _ = c.Write(ctx, websocket.MessageText, []byte("4hello")) + // Then a server ping "2"; expect pong "3". + _ = c.Write(ctx, websocket.MessageText, []byte("2")) + _, pong, _ := c.Read(ctx) + if string(pong) != "3" { + t.Errorf("pong = %q", pong) + } + time.Sleep(20 * time.Millisecond) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestEngineIOHandshakeAndUpgrade(t *testing.T) { + srv := fakeEIOServer(t) + eng, err := Dial(context.Background(), DialOptions{ + BaseURL: srv.URL, + Header: http.Header{"Authorization": {"Bearer tok"}}, + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + if eng.SID() != "abc" { + t.Errorf("sid = %q, want abc", eng.SID()) + } + pkt, err := eng.Read(context.Background()) + if err != nil { + t.Fatalf("Read: %v", err) + } + if pkt.Type != '4' || string(pkt.Data) != "hello" { + t.Errorf("pkt = %c%q", pkt.Type, pkt.Data) + } + // The transport must auto-answer the server ping with a pong (asserted + // server-side). Give the heartbeat goroutine a moment. + time.Sleep(30 * time.Millisecond) +} diff --git a/internal/wpstream/iostream.go b/internal/wpstream/iostream.go new file mode 100644 index 000000000..0f7891fe2 --- /dev/null +++ b/internal/wpstream/iostream.go @@ -0,0 +1,391 @@ +package wpstream + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/google/uuid" +) + +const streamEvent = "$stream" // socket.io-stream EVENT_NAME + +// StreamSocket wraps a Client to provide the socket.io-stream subprotocol. +// Port of socket.io-stream/lib/socket.js. +type StreamSocket struct { + cli *Client + ctx context.Context + + mu sync.Mutex + streams map[string]*IOStream + handlers map[string][]func(args []any, ackID *int) +} + +func NewStreamSocket(ctx context.Context, cli *Client) *StreamSocket { + ss := &StreamSocket{ + cli: cli, ctx: ctx, + streams: map[string]*IOStream{}, + handlers: map[string][]func([]any, *int){}, + } + cli.On(streamEvent, func(args []any) { ss.onStreamEvent(args) }) + cli.OnRaw(streamEvent+"-write", ss.onWrite) // needs ack id + cli.On(streamEvent+"-read", func(a []any) { ss.onRead(a) }) + cli.On(streamEvent+"-end", func(a []any) { ss.onEnd(a) }) + cli.On(streamEvent+"-error", func(a []any) { ss.onError(a) }) + return ss +} + +func (ss *StreamSocket) On(event string, h func(args []any, ackID *int)) { + ss.mu.Lock() + ss.handlers[event] = append(ss.handlers[event], h) + ss.mu.Unlock() +} + +func (ss *StreamSocket) CreateStream() *IOStream { + s := newIOStream(ss, uuid.NewString()) + ss.register(s) + return s +} + +func (ss *StreamSocket) register(s *IOStream) { + ss.mu.Lock() + ss.streams[s.id] = s + ss.mu.Unlock() +} + +// cleanup removes a stream from the map (M2 — prevents leak across reconnects). +func (ss *StreamSocket) cleanup(id string) { + ss.mu.Lock() + delete(ss.streams, id) + ss.mu.Unlock() +} + +// abortAll snapshots the streams map and aborts each one (C3). +func (ss *StreamSocket) abortAll(err error) { + ss.mu.Lock() + snapshot := make([]*IOStream, 0, len(ss.streams)) + for _, s := range ss.streams { + snapshot = append(snapshot, s) + } + ss.mu.Unlock() + for _, s := range snapshot { + s.abort(err) + } +} + +func (ss *StreamSocket) Emit(ctx context.Context, event string, args []any, ack func([]any)) error { + enc := make([]any, 0, len(args)) + for _, a := range args { + enc = append(enc, ss.encodeArg(a)) + } + full := append([]any{event}, enc...) + return ss.cli.Emit(ctx, streamEvent, full, ack) +} + +func (ss *StreamSocket) encodeArg(v any) any { + switch t := v.(type) { + case *IOStream: + ss.register(t) + return map[string]any{"$stream": t.id} + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = ss.encodeArg(e) + } + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = ss.encodeArg(e) + } + return out + default: + return v + } +} + +func (ss *StreamSocket) decodeArg(v any) any { + switch t := v.(type) { + case map[string]any: + if id, ok := t["$stream"].(string); ok && id != "" { + s := newIOStream(ss, id) + ss.register(s) + return s + } + for k, e := range t { + t[k] = ss.decodeArg(e) + } + return t + case []any: + for i, e := range t { + t[i] = ss.decodeArg(e) + } + return t + default: + return v + } +} + +func (ss *StreamSocket) onStreamEvent(args []any) { + if len(args) == 0 { + return + } + event, _ := args[0].(string) + rest := make([]any, 0, len(args)-1) + for _, a := range args[1:] { + rest = append(rest, ss.decodeArg(a)) + } + ss.mu.Lock() + hs := append([]func([]any, *int){}, ss.handlers[event]...) + ss.mu.Unlock() + // Dispatch user handlers in goroutines so the Client readLoop is not + // blocked. If a handler calls io.ReadAll on an IOStream, it will itself + // emit $stream-read credits, which must be processed by this same readLoop + // — dispatching inline would deadlock. + for _, h := range hs { + h := h + go h(rest, nil) + } +} + +func (ss *StreamSocket) sendRead(id string, size int) { + _ = ss.cli.Emit(ss.ctx, streamEvent+"-read", []any{id, size}, nil) +} + +// sendWrite sends a $stream-write packet. Returns any transport error (M1). +func (ss *StreamSocket) sendWrite(id string, chunk []byte, ack func([]any)) error { + return ss.cli.Emit(ss.ctx, streamEvent+"-write", + []any{id, binaryArg(chunk), "buffer"}, ack) +} + +func (ss *StreamSocket) sendEnd(id string) { + _ = ss.cli.Emit(ss.ctx, streamEvent+"-end", []any{id}, nil) +} + +func (ss *StreamSocket) get(id string) *IOStream { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.streams[id] +} + +func (ss *StreamSocket) onRead(args []any) { + id, _ := args[0].(string) + if s := ss.get(id); s != nil { + s.grantWriteCredit() + } +} + +func (ss *StreamSocket) onWrite(args []any, ackID *int) { + id, _ := args[0].(string) + var chunk []byte + if b, ok := args[1].([]byte); ok { + chunk = b + } + s := ss.get(id) + if s == nil { + return + } + // deliver blocks until the consumer reads — this is intentional backpressure. + // The ack fires AFTER deliver returns (matching Node socket.io-stream semantics: + // the callback fires after the consumer pulls). The readLoop goroutine may block + // here, but since the ack is sent via WriteMessage (a buffered channel push to + // the peer) and the peer's readLoop is independent, there is no circular wait. + s.deliver(chunk) + if ackID != nil { + _ = ss.cli.ackReply(ss.ctx, *ackID, nil) + } +} + +func (ss *StreamSocket) onEnd(args []any) { + id, _ := args[0].(string) + if s := ss.get(id); s != nil { + s.deliverEOF() + } +} + +func (ss *StreamSocket) onError(args []any) { + id, _ := args[0].(string) + msg := "" + if len(args) > 1 { + msg, _ = args[1].(string) + } + if s := ss.get(id); s != nil { + s.abort(fmt.Errorf("wpstream: remote stream error: %s", msg)) + } +} + +// IOStream is a duplex stream over the socket.io-stream subprotocol. +// It implements io.ReadWriteCloser. +// +// Flow control: reading triggers a $stream-read credit to the remote sender; +// the sender waits for that credit before flushing one chunk via $stream-write. +// Write blocks until a credit arrives (from the remote reader calling Read) and +// until the remote acknowledges receipt (ack from $stream-write handler). +// +// Teardown: abort(err) closes the `closed` channel, unblocking all blocked +// Read/Write/deliver calls. deliverEOF() is a normal end-of-stream; it uses a +// separate sync.Once-guarded readEOF channel so buffered data can still drain. +type IOStream struct { + ss *StreamSocket + id string + + readBuf chan []byte + readEOF chan struct{} + leftover []byte + readReqd bool + + writeCredit chan struct{} + + // closed is closed once by abort() or by the explicit Close() teardown path. + // It is the escape hatch for blocked Read/Write/deliver calls. + closed chan struct{} + closeOnce sync.Once // guards close(closed) + cleanup + abortErr error // set before close(closed); nil means normal close / EOF + + // eofOnce guards close(readEOF) so a duplicate $stream-end never panics (I2). + eofOnce sync.Once + + // sendEndOnce ensures $stream-end is sent exactly once by Close(). + sendEndOnce sync.Once + + mu sync.Mutex +} + +// Ensure IOStream satisfies io.ReadWriteCloser at compile time. +var _ io.ReadWriteCloser = (*IOStream)(nil) + +func newIOStream(ss *StreamSocket, id string) *IOStream { + return &IOStream{ + ss: ss, id: id, + readBuf: make(chan []byte, 1), + readEOF: make(chan struct{}), + writeCredit: make(chan struct{}, 1), + closed: make(chan struct{}), + } +} + +// abort terminates the stream with the given error, unblocking all blocked +// Read/Write/deliver calls. Idempotent (I2, I3). Called on disconnect (C3) +// and on remote stream error. +func (s *IOStream) abort(err error) { + s.closeOnce.Do(func() { + s.abortErr = err + close(s.closed) + s.ss.cleanup(s.id) + }) +} + +// abortErrOrClosed returns the abort error, or io.ErrClosedPipe if the +// stream was closed without an error (normal Close path). +func (s *IOStream) abortErrOrClosed() error { + if s.abortErr != nil { + return s.abortErr + } + return io.ErrClosedPipe +} + +// Read implements io.Reader. On the first call (or after consuming a previous +// chunk) it sends a $stream-read credit to the remote, then blocks until a +// chunk, EOF, or error arrives. Also unblocks when the stream is aborted (I3). +func (s *IOStream) Read(p []byte) (int, error) { + if len(s.leftover) > 0 { + n := copy(p, s.leftover) + s.leftover = s.leftover[n:] + return n, nil + } + s.mu.Lock() + if !s.readReqd { + s.readReqd = true + s.mu.Unlock() + s.ss.sendRead(s.id, len(p)) + } else { + s.mu.Unlock() + } + select { + case chunk := <-s.readBuf: + s.mu.Lock() + s.readReqd = false + s.mu.Unlock() + n := copy(p, chunk) + if n < len(chunk) { + s.leftover = chunk[n:] + } + return n, nil + case <-s.readEOF: + return 0, io.EOF + case <-s.closed: + // drain any chunk that raced with abort + select { + case chunk := <-s.readBuf: + s.mu.Lock() + s.readReqd = false + s.mu.Unlock() + n := copy(p, chunk) + if n < len(chunk) { + s.leftover = chunk[n:] + } + return n, nil + default: + } + return 0, s.abortErrOrClosed() + } +} + +// deliver pushes one chunk into the read buffer. Called by onWrite on the +// readLoop goroutine. Does not block forever after abort (I2). +func (s *IOStream) deliver(chunk []byte) { + select { + case s.readBuf <- chunk: + case <-s.closed: + } +} + +// deliverEOF signals EOF to any blocked Read call. Idempotent (I2). +func (s *IOStream) deliverEOF() { + s.eofOnce.Do(func() { close(s.readEOF) }) +} + +// grantWriteCredit unblocks one pending Write call. +func (s *IOStream) grantWriteCredit() { + select { + case s.writeCredit <- struct{}{}: + default: + } +} + +// Write implements io.Writer. Blocks until a read-credit arrives from the +// remote (i.e., the remote called Read, which sent $stream-read), then sends +// the chunk and blocks until the remote ACKs receipt. +// Both waits are escapable via the closed channel (I3). Emit errors are +// propagated (M1). +func (s *IOStream) Write(p []byte) (int, error) { + select { + case <-s.writeCredit: + case <-s.closed: + return 0, s.abortErrOrClosed() + } + acked := make(chan struct{}) + if err := s.ss.sendWrite(s.id, p, func([]any) { close(acked) }); err != nil { + return 0, err + } + select { + case <-acked: + case <-s.closed: + return 0, s.abortErrOrClosed() + } + return len(p), nil +} + +// Close implements io.Closer. Sends $stream-end exactly once and marks the +// stream closed. Idempotent. +func (s *IOStream) Close() error { + s.sendEndOnce.Do(func() { s.ss.sendEnd(s.id) }) + // Also mark closed so any concurrent Write/Read unblocks (I3). + s.closeOnce.Do(func() { + // abortErr stays nil → abortErrOrClosed returns io.ErrClosedPipe. + close(s.closed) + s.ss.cleanup(s.id) + }) + return nil +} diff --git a/internal/wpstream/iostream_test.go b/internal/wpstream/iostream_test.go new file mode 100644 index 000000000..43431fb63 --- /dev/null +++ b/internal/wpstream/iostream_test.go @@ -0,0 +1,138 @@ +package wpstream + +import ( + "bytes" + "context" + "io" + "testing" + "time" +) + +// pipeTransport is an in-memory transport pair for loopback tests. +// WriteMessage/WriteBinary push packets onto the PEER's inbound channel, +// exactly mirroring what Engine.Read would return after stripping the EIO framing. +type pipeTransport struct { + peer *pipeTransport + in chan Packet +} + +func (p *pipeTransport) Read(ctx context.Context) (Packet, error) { + select { + case pkt := <-p.in: + return pkt, nil + case <-ctx.Done(): + return Packet{}, ctx.Err() + } +} + +// WriteMessage receives the raw socket.io packet string (Client.sendPacket +// passes f.Data which is the sio string; real Engine.WriteMessage adds the +// '4' prefix). We deliver to the peer as Packet{Type: eioMessage, Data: copy} +// which is exactly what Engine.Read returns after stripping the '4'. +func (p *pipeTransport) WriteMessage(ctx context.Context, payload []byte) error { + buf := make([]byte, len(payload)) + copy(buf, payload) + select { + case p.peer.in <- Packet{Type: eioMessage, Data: buf}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// WriteBinary receives raw binary attachment bytes. We deliver to the peer as +// Packet{Binary: true, Data: copy}, matching what Engine.Read returns for a +// WebSocket binary frame. +func (p *pipeTransport) WriteBinary(ctx context.Context, data []byte) error { + buf := make([]byte, len(data)) + copy(buf, data) + select { + case p.peer.in <- Packet{Type: eioMessage, Data: buf, Binary: true}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// newLoopbackStreamSockets builds an in-memory loopback: two pipeTransports +// cross-linked, two Clients with their readLoops running, wrapped in StreamSockets. +// No Connect handshake is needed — we skip directly to readLoop. +func newLoopbackStreamSockets(t *testing.T) (*StreamSocket, *StreamSocket) { + t.Helper() + + ta := &pipeTransport{in: make(chan Packet, 64)} + tb := &pipeTransport{in: make(chan Packet, 64)} + ta.peer = tb + tb.peer = ta + + cliA := NewClient(ta, "/wp-cli") + cliB := NewClient(tb, "/wp-cli") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go cliA.readLoop(ctx) + go cliB.readLoop(ctx) + + a := NewStreamSocket(ctx, cliA) + b := NewStreamSocket(ctx, cliB) + return a, b +} + +func TestStreamReadFromRemote(t *testing.T) { + a, b := newLoopbackStreamSockets(t) + + bStream := b.CreateStream() + got := make(chan []byte, 1) + a.On("cmd", func(args []any, _ *int) { + s := args[len(args)-1].(*IOStream) + data, _ := io.ReadAll(s) + got <- data + }) + + ctx := context.Background() + if err := b.Emit(ctx, "cmd", []any{"meta", bStream}, nil); err != nil { + t.Fatal(err) + } + go func() { + _, _ = bStream.Write([]byte("hello")) + _ = bStream.Close() + }() + + select { + case d := <-got: + if !bytes.Equal(d, []byte("hello")) { + t.Errorf("read = %q", d) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout reading remote stream") + } +} + +func TestStreamWriteToRemote(t *testing.T) { + a, b := newLoopbackStreamSockets(t) + aStream := a.CreateStream() + + done := make(chan []byte, 1) + b.On("cmd", func(args []any, _ *int) { + s := args[len(args)-1].(*IOStream) + data, _ := io.ReadAll(s) + done <- data + }) + ctx := context.Background() + if err := a.Emit(ctx, "cmd", []any{"meta", aStream}, nil); err != nil { + t.Fatal(err) + } + go func() { + _, _ = aStream.Write([]byte("from-a")) + _ = aStream.Close() + }() + select { + case d := <-done: + if !bytes.Equal(d, []byte("from-a")) { + t.Errorf("got %q", d) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} diff --git a/internal/wpstream/run.go b/internal/wpstream/run.go new file mode 100644 index 000000000..d0b08d5e2 --- /dev/null +++ b/internal/wpstream/run.go @@ -0,0 +1,355 @@ +package wpstream + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "math" + "math/rand/v2" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/fatih/color" +) + +const ( + nonTTYColumns = 100 // NON_TTY_COLUMNS (vip-wp.js:42) + nonTTYRows = 15 // NON_TTY_ROWS (vip-wp.js:43) +) + +// errRunDone is injected into a stdout IOStream to interrupt a blocked Read +// when run() is about to return (exit/cancel received before stdout EOF). +var errRunDone = errors.New("wpstream: run done") + +// Options configure a single Run. +type Options struct { + APIHost string + Token string + GUID string + InputToken string + Columns int + Rows int + IsTTY bool // CR→LF stdin normalization when true (vip-wp.js:51) + + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// Result carries the terminal outcome (the caller maps ExitCode to os.Exit). +type Result struct { + ExitCode int +} + +// Run connects and executes one WP-CLI command over socket.io. +// It implements the reconnect/offset loop: on disconnect (before an exit event +// arrives) it re-dials with exponential backoff and resumes from offset. +// +// C1/C2 fix: the loop is entirely self-contained; every Engine is explicitly +// closed before the next attempt or before returning. No goroutine is launched +// that outlives its engine. +func Run(ctx context.Context, opts Options) (Result, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var offset atomic.Int64 + backoff := time.Second + const maxBackoff = 5 * time.Second + first := true + + for { + eng, err := Dial(ctx, DialOptions{ + BaseURL: opts.APIHost, + Header: bearerHeader(opts.Token), + }) + if err != nil { + if first { + return Result{}, err + } + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + continue + } + + cli := NewClient(eng, "/wp-cli") + ss := NewStreamSocket(ctx, cli) + + // Install a retry handler: the server sends "retry" to signal it wants us to + // reconnect. We close the engine after 5 s to force the disconnect. + cli.On("retry", func(args []any) { + go func() { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } + eng.Close() + }() + }) + + if err := cli.Connect(ctx); err != nil { + eng.Close() + if first { + return Result{}, err + } + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + continue + } + first = false + + res, clean := runOnce(ctx, opts, cli, ss, &offset, offset.Load()) + eng.Close() // C2: ALWAYS close engine before next attempt or return + + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + if clean { + return res, nil + } + + // disconnected mid-command → wait then reconnect from offset + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + backoff = time.Second // reset after a successful (even if interrupted) attempt + } +} + +// waitBackoff sleeps for a jittered backoff duration, then doubles backoff up +// to max. Returns ctx.Err() if the context is cancelled during the wait. +func waitBackoff(ctx context.Context, backoff *time.Duration, max time.Duration) error { + jitter := time.Duration(float64(*backoff) * (0.5 + rand.Float64()*0.5)) + select { + case <-time.After(jitter): + case <-ctx.Done(): + return ctx.Err() + } + *backoff = time.Duration(math.Min(float64(*backoff*2), float64(max))) + return nil +} + +// runOnce executes one attempt: registers handlers, launches the command, +// pipes stdio, and waits for exit, stdout EOF, disconnect, or ctx cancel. +// +// Returns (result, true) on clean exit, or (Result{}, false) on disconnect +// so the caller can reconnect. ctx cancel is treated as clean=true and the +// caller checks ctx.Err() afterwards. +func runOnce(ctx context.Context, opts Options, cli *Client, ss *StreamSocket, offset *atomic.Int64, resumeAt int64) (Result, bool) { + // exitCh is buffered: handlers run in goroutines and MUST NOT block on send. + exitCh := make(chan int, 4) + signalExit := func(code int) { + select { + case exitCh <- code: + default: + } + } + + cli.On("unauthorized", func(args []any) { + fmt.Fprintln(opts.Stdout, "There was an error with the authentication:", errMessage(args)) + }) + cli.On("cancel", func(args []any) { + fmt.Fprintf(opts.Stdout, "Cancel received from server: %s\n", strArg(args)) + signalExit(1) + }) + cli.On("error", func(args []any) { + if strArg(args) == "Rate limit exceeded" { + fmt.Fprintln(opts.Stdout, color.RedString("\nError:"), + "Rate limit exceeded: Please wait a moment and try again.") + return + } + fmt.Fprintln(opts.Stdout, strArg(args)) + }) + cli.On("exit", func(args []any) { + code, msg := parseExit(args) + if msg != "" { + fmt.Fprintln(opts.Stdout, msg) + } + signalExit(code) + }) + + disconnected := make(chan struct{}, 1) + cli.On("disconnect", func(args []any) { + select { + case disconnected <- struct{}{}: + default: + } + }) + + stdoutDone := make(chan struct{}) + // runDone is closed when runOnce() is about to return, allowing the stdout + // goroutine to exit even if the server never closes the stdout stream. + runDone := make(chan struct{}) + var runDoneOnce sync.Once + closeRunDone := func() { runDoneOnce.Do(func() { close(runDone) }) } + + stdinStream := ss.CreateStream() + stdoutStream := ss.CreateStream() + + cols := opts.Columns + if cols == 0 { + cols = nonTTYColumns + } + rows := opts.Rows + if rows == 0 { + rows = nonTTYRows + } + data := map[string]any{ + "guid": opts.GUID, + "inputToken": opts.InputToken, + "columns": cols, + "rows": rows, + "offset": resumeAt, + } + _ = ss.Emit(ctx, "cmd", []any{data, stdinStream, stdoutStream}, nil) + + // Pipe stdin → stdinStream. + // When stdinStream is aborted (C3) Write returns an error, io.Copy stops, + // and the goroutine exits — no leak. + go func() { + src := opts.Stdin + if opts.IsTTY && src != nil { + src = crToLF{src} + } + if src != nil { + _, _ = io.Copy(stdinStream, src) + } + _ = stdinStream.Close() + }() + + // Background watcher: inject errRunDone into stdoutStream when runOnce is + // about to return, unblocking the stdout goroutine. + go func() { + select { + case <-runDone: + stdoutStream.abort(errRunDone) + case <-stdoutDone: + } + }() + // Pipe stdoutStream → opts.Stdout, tracking byte offset. + go func() { + buf := make([]byte, 32*1024) + for { + n, rerr := stdoutStream.Read(buf) + if n > 0 { + _, _ = opts.Stdout.Write(buf[:n]) + if offset != nil { + offset.Add(int64(n)) + } + } + if rerr != nil { + close(stdoutDone) + return + } + } + }() + + defer closeRunDone() + + var exitCode int + select { + case exitCode = <-exitCh: + // Drain stdout: signal runDone so the watcher goroutine aborts the + // stream, unblocking the stdout goroutine. + closeRunDone() + <-stdoutDone + return Result{ExitCode: exitCode}, true + case <-stdoutDone: + // stdout EOF before any exit event — the server always sends an 'exit' + // event after streaming completes (vip-wp.js:261). Wait briefly for it + // so a non-zero exit code is not silently lost. + select { + case exitCode = <-exitCh: + case <-time.After(2 * time.Second): + case <-ctx.Done(): + } + return Result{ExitCode: exitCode}, true + case <-disconnected: + // Transport dropped mid-command. + // C3: abort all in-flight streams so blocked Read/Write goroutines exit. + // We do this here (not in NewStreamSocket) to ensure the disconnected + // channel is selected BEFORE stdoutDone can fire — preventing the race + // where stdoutDone fires first and runOnce returns clean=true incorrectly. + ss.abortAll(io.ErrUnexpectedEOF) + closeRunDone() // also aborts stdoutStream via watcher (redundant but safe) + return Result{}, false + case <-ctx.Done(): + return Result{}, true // caller checks ctx.Err() + } +} + +// run is the internal single-attempt function used by the unit tests (run_test.go). +// The public API uses runOnce via Run. Kept for backward compatibility with tests. +func run(ctx context.Context, opts Options, cli *Client, ss *StreamSocket, offset *atomic.Int64, resumeAt int64) (Result, error) { + res, clean := runOnce(ctx, opts, cli, ss, offset, resumeAt) + if !clean { + // disconnect treated as context cancellation for unit-test callers + return Result{}, ctx.Err() + } + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + return res, nil +} + +// crToLF replaces '\r' with '\n' (normalizeNewlineStream, vip-wp.js:51). +type crToLF struct{ r io.Reader } + +func (c crToLF) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + for i := 0; i < n; i++ { + if p[i] == '\r' { + p[i] = '\n' + } + } + return n, err +} + +func bearerHeader(token string) http.Header { + return http.Header{"Authorization": {"Bearer " + token}} +} + +func parseExit(args []any) (int, string) { + if len(args) == 0 { + return 0, "" + } + m, ok := args[0].(map[string]any) + if !ok { + return 0, "" + } + code := 0 + if c, ok := m["exitCode"].(float64); ok { + code = int(c) + } + msg, _ := m["message"].(string) + return code, msg +} + +func strArg(args []any) string { + if len(args) == 0 { + return "" + } + if s, ok := args[0].(string); ok { + return s + } + b, _ := json.Marshal(args[0]) + return string(b) +} + +func errMessage(args []any) string { + if len(args) == 0 { + return "" + } + if m, ok := args[0].(map[string]any); ok { + if s, ok := m["message"].(string); ok { + return s + } + } + return strArg(args) +} diff --git a/internal/wpstream/run_test.go b/internal/wpstream/run_test.go new file mode 100644 index 000000000..655406c05 --- /dev/null +++ b/internal/wpstream/run_test.go @@ -0,0 +1,184 @@ +package wpstream + +import ( + "bytes" + "context" + "strings" + "sync/atomic" + "testing" + "time" +) + +// loopbackPair holds both sides of an in-memory loopback for run tests. +type loopbackPair struct { + // client (A) side — passed to run() + ssA *StreamSocket + cliA *Client + // server (B) side — scripted in tests + ssB *StreamSocket + cliB *Client +} + +// newLoopbackPair builds a cross-linked loopback and returns both sides. +func newLoopbackPair(t *testing.T) loopbackPair { + t.Helper() + + ta := &pipeTransport{in: make(chan Packet, 64)} + tb := &pipeTransport{in: make(chan Packet, 64)} + ta.peer = tb + tb.peer = ta + + cliA := NewClient(ta, "/wp-cli") + cliB := NewClient(tb, "/wp-cli") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go cliA.readLoop(ctx) + go cliB.readLoop(ctx) + + ssA := NewStreamSocket(ctx, cliA) + ssB := NewStreamSocket(ctx, cliB) + + return loopbackPair{ssA: ssA, cliA: cliA, ssB: ssB, cliB: cliB} +} + +// script is a function that scripts the "server" (B) side when it receives a "cmd" event. +// args are the decoded arguments: [data, stdinStream, stdoutStream]. +type script func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) + +// runWithScript runs run() on the A side while B executes the given script. +// Returns the Result and the captured stdout (combined with opts.Stdout). +func runWithScript(t *testing.T, s script) (Result, string) { + t.Helper() + t.Setenv("NO_COLOR", "1") + + pair := newLoopbackPair(t) + + var buf bytes.Buffer + opts := Options{ + Stdin: strings.NewReader(""), // empty stdin + Stdout: &buf, + Stderr: &buf, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + // Script the server side: register a "cmd" handler on ssB. + // The handler will be called in its own goroutine (per StreamSocket.On dispatch). + pair.ssB.On("cmd", func(args []any, ackID *int) { + s(ctx, args, pair.cliB, pair.ssB) + }) + + // Run the inner function with the A-side client + StreamSocket. + var offset atomic.Int64 + resCh := make(chan Result, 1) + errCh := make(chan error, 1) + go func() { + res, err := run(ctx, opts, pair.cliA, pair.ssA, &offset, 0) + if err != nil { + errCh <- err + return + } + resCh <- res + }() + + select { + case res := <-resCh: + return res, buf.String() + case err := <-errCh: + t.Fatalf("run() returned error: %v", err) + return Result{}, "" + case <-time.After(4 * time.Second): + t.Fatal("timeout waiting for run() to complete") + return Result{}, "" + } +} + +// scriptExit returns a script that emits an exit event with the given code and message. +func scriptExit(code int, message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(code), + "message": message, + }}, nil) + } +} + +// scriptCancel returns a script that emits a cancel event with the given message. +func scriptCancel(message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "cancel", []any{message}, nil) + } +} + +// scriptError returns a script that emits an error event with the given message. +func scriptError(message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "error", []any{message}, nil) + // After error we still need to signal exit so run() terminates. + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(1), + }}, nil) + } +} + +// scriptStdout returns a script that writes data to the stdout stream (args[2]), +// closes it, then emits exit with the given code. +func scriptStdout(data string, code int) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + // args: [data(map), stdinStream(*IOStream), stdoutStream(*IOStream)] + if len(args) < 3 { + return + } + stdoutStream, ok := args[2].(*IOStream) + if !ok { + return + } + // Write data then close the stdout stream. + _, _ = stdoutStream.Write([]byte(data)) + _ = stdoutStream.Close() + // Emit exit to be deterministic. + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(code), + }}, nil) + } +} + +func TestRunExitEvent(t *testing.T) { + res, out := runWithScript(t, scriptExit(5, "done")) + if res.ExitCode != 5 { + t.Errorf("exit = %d, want 5", res.ExitCode) + } + if !strings.Contains(out, "done") { + t.Errorf("message not printed: %q", out) + } +} + +func TestRunCancelEvent(t *testing.T) { + res, out := runWithScript(t, scriptCancel("nope")) + if res.ExitCode != 1 { + t.Errorf("exit = %d, want 1", res.ExitCode) + } + if !strings.Contains(out, "Cancel received from server: nope") { + t.Errorf("out = %q", out) + } +} + +func TestRunRateLimitError(t *testing.T) { + _, out := runWithScript(t, scriptError("Rate limit exceeded")) + if !strings.Contains(out, "Rate limit exceeded: Please wait a moment and try again.") { + t.Errorf("out = %q", out) + } +} + +func TestRunStdoutStreamed(t *testing.T) { + res, out := runWithScript(t, scriptStdout("line1\nline2\n", 0)) + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if !strings.Contains(out, "line1") || !strings.Contains(out, "line2") { + t.Errorf("stdout not streamed: %q", out) + } +} diff --git a/internal/wpstream/socketio.go b/internal/wpstream/socketio.go new file mode 100644 index 000000000..984ab8413 --- /dev/null +++ b/internal/wpstream/socketio.go @@ -0,0 +1,435 @@ +package wpstream + +import ( + "bytes" + "context" + "encoding/json/v2" + "fmt" + "strconv" + "sync" + "sync/atomic" +) + +// Socket.IO v4 packet types (socket.io-parser, protocol 5). +const ( + sioConnect = 0 + sioDisconnect = 1 + sioEvent = 2 + sioAck = 3 + sioConnectError = 4 + sioBinaryEvent = 5 + sioBinaryAck = 6 +) + +// binaryArg wraps a []byte so the encoder emits it as a socket.io binary +// attachment (placeholder + separate frame) rather than JSON. +type binaryArg []byte + +// sioPacket is a decoded/decodable Socket.IO packet. +type sioPacket struct { + Type int + Nsp string + ID *int // ack id + Data []any // event name + args (decoded; binary args are []byte) + attachments int // BINARY_* only +} + +func intPtr(i int) *int { return &i } + +// encodePacket renders a packet to one text frame plus N binary frames. +func encodePacket(p sioPacket) ([]Packet, error) { + typ := p.Type + var attachments [][]byte + data := p.Data + + if hasBinary(data) { + switch typ { + case sioEvent: + typ = sioBinaryEvent + case sioAck: + typ = sioBinaryAck + } + deconstructed, bufs := deconstruct(data) + data, _ = deconstructed.([]any) + attachments = bufs + } + + var b bytes.Buffer + b.WriteString(strconv.Itoa(typ)) + if typ == sioBinaryEvent || typ == sioBinaryAck { + b.WriteString(strconv.Itoa(len(attachments))) + b.WriteByte('-') + } + if p.Nsp != "" && p.Nsp != "/" { + b.WriteString(p.Nsp) + b.WriteByte(',') + } + if p.ID != nil { + b.WriteString(strconv.Itoa(*p.ID)) + } + if data != nil { + j, err := json.Marshal(data) + if err != nil { + return nil, err + } + b.Write(j) + } + + frames := []Packet{{Type: eioMessage, Data: b.Bytes()}} + for _, a := range attachments { + frames = append(frames, Packet{Type: eioMessage, Data: a, Binary: true}) + } + return frames, nil +} + +func hasBinary(v any) bool { + switch t := v.(type) { + case binaryArg: + return true + case []any: + for _, e := range t { + if hasBinary(e) { + return true + } + } + case map[string]any: + for _, e := range t { + if hasBinary(e) { + return true + } + } + } + return false +} + +// deconstruct walks data, replacing each binaryArg with a placeholder and +// collecting the raw bytes (socket.io-parser binary.js). +func deconstruct(v any) (any, [][]byte) { + var bufs [][]byte + var walk func(any) any + walk = func(x any) any { + switch t := x.(type) { + case binaryArg: + ph := map[string]any{"_placeholder": true, "num": len(bufs)} + bufs = append(bufs, []byte(t)) + return ph + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = walk(e) + } + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = walk(e) + } + return out + default: + return x + } + } + return walk(v), bufs +} + +// sioDecoder reassembles packets, buffering binary attachments. +type sioDecoder struct { + pending *sioPacket + placeholds int + bufs [][]byte +} + +func newSioDecoder() *sioDecoder { return &sioDecoder{} } + +// add feeds one Engine.IO packet. Returns (packet, true) when a full Socket.IO +// packet is assembled, or (_, false) while awaiting binary attachments. +func (d *sioDecoder) add(pkt Packet) (sioPacket, bool, error) { + if pkt.Binary { + if d.pending == nil { + return sioPacket{}, false, fmt.Errorf("wpstream: unexpected binary frame") + } + d.bufs = append(d.bufs, pkt.Data) + if len(d.bufs) < d.placeholds { + return sioPacket{}, false, nil + } + p := *d.pending + p.Data = reconstruct(p.Data, d.bufs).([]any) + d.pending, d.placeholds, d.bufs = nil, 0, nil + return p, true, nil + } + + p, attachments, err := decodeString(pkt.Data) + if err != nil { + return sioPacket{}, false, err + } + if attachments == 0 { + return p, true, nil + } + d.pending, d.placeholds, d.bufs = &p, attachments, nil + return sioPacket{}, false, nil +} + +// decodeString parses the text portion. Returns the packet plus the number of +// expected binary attachments. +func decodeString(b []byte) (sioPacket, int, error) { + if len(b) == 0 { + return sioPacket{}, 0, fmt.Errorf("wpstream: empty packet") + } + i := 0 + typ := int(b[i] - '0') + i++ + attachments := 0 + if typ == sioBinaryEvent || typ == sioBinaryAck { + j := i + for j < len(b) && b[j] != '-' { + j++ + } + n, _ := strconv.Atoi(string(b[i:j])) + attachments = n + i = j + 1 + } + nsp := "/" + if i < len(b) && b[i] == '/' { + j := i + for j < len(b) && b[j] != ',' { + j++ + } + nsp = string(b[i:j]) + if j < len(b) { + j++ // skip comma + } + i = j + } + var idp *int + if i < len(b) && b[i] >= '0' && b[i] <= '9' { + j := i + for j < len(b) && b[j] >= '0' && b[j] <= '9' { + j++ + } + id, _ := strconv.Atoi(string(b[i:j])) + idp = &id + i = j + } + p := sioPacket{Type: typ, Nsp: nsp, ID: idp, attachments: attachments} + if i < len(b) { + rest := b[i:] + if len(rest) > 0 && rest[0] == '[' { + // EVENT / ACK: JSON array of [eventName, ...args] + var data []any + if err := json.Unmarshal(rest, &data); err != nil { + return sioPacket{}, 0, fmt.Errorf("wpstream: decode data: %w", err) + } + p.Data = data + } else { + // CONNECT / CONNECT_ERROR: JSON object payload, store as single element. + var obj any + if err := json.Unmarshal(rest, &obj); err != nil { + return sioPacket{}, 0, fmt.Errorf("wpstream: decode data: %w", err) + } + p.Data = []any{obj} + } + } + return p, attachments, nil +} + +// reconstruct replaces {"_placeholder":true,"num":N} markers with bufs[N]. +func reconstruct(v any, bufs [][]byte) any { + switch t := v.(type) { + case map[string]any: + if ph, _ := t["_placeholder"].(bool); ph { + if num, ok := t["num"].(float64); ok && int(num) < len(bufs) { + return bufs[int(num)] + } + } + for k, e := range t { + t[k] = reconstruct(e, bufs) + } + return t + case []any: + for i, e := range t { + t[i] = reconstruct(e, bufs) + } + return t + default: + return v + } +} + +// transport is the Engine.IO interface that Client writes to and reads from. +// *Engine satisfies it; tests may substitute an in-memory loopback (Task 3). +type transport interface { + Read(ctx context.Context) (Packet, error) + WriteMessage(ctx context.Context, payload []byte) error + WriteBinary(ctx context.Context, data []byte) error +} + +// Client is a Socket.IO v4 namespace client over a transport. +type Client struct { + eng transport + nsp string + dec *sioDecoder + + mu sync.Mutex + handlers map[string][]func(args []any) + rawHandlers map[string][]func(args []any, ackID *int) + ackID atomic.Int64 + acks map[int]func(args []any) + connected chan struct{} + connErr chan error +} + +// NewClient creates a Socket.IO namespace client over the given transport. +func NewClient(eng transport, nsp string) *Client { + return &Client{ + eng: eng, nsp: nsp, dec: newSioDecoder(), + handlers: map[string][]func([]any){}, + rawHandlers: map[string][]func([]any, *int){}, + acks: map[int]func([]any){}, + connected: make(chan struct{}), connErr: make(chan error, 1), + } +} + +// On registers a handler for the named event. +func (c *Client) On(event string, h func(args []any)) { + c.mu.Lock() + c.handlers[event] = append(c.handlers[event], h) + c.mu.Unlock() +} + +// OnRaw registers a handler for the named event that also receives the ack id. +// Raw handlers fire before plain On handlers. The iostream layer uses this to +// send ACK replies for $stream-write events. +func (c *Client) OnRaw(event string, h func(args []any, ackID *int)) { + c.mu.Lock() + c.rawHandlers[event] = append(c.rawHandlers[event], h) + c.mu.Unlock() +} + +// Emit sends an event to the server. If ack is non-nil the packet carries an +// ack id and ack will be called when the server replies. +func (c *Client) Emit(ctx context.Context, event string, args []any, ack func([]any)) error { + p := sioPacket{Type: sioEvent, Nsp: c.nsp, Data: append([]any{event}, args...)} + if ack != nil { + id := int(c.ackID.Add(1)) + p.ID = &id + c.mu.Lock() + c.acks[id] = ack + c.mu.Unlock() + } + return c.sendPacket(ctx, p) +} + +// ackReply sends a Socket.IO ACK for a previously received event id. +func (c *Client) ackReply(ctx context.Context, id int, args []any) error { + return c.sendPacket(ctx, sioPacket{Type: sioAck, Nsp: c.nsp, ID: &id, Data: args}) +} + +func (c *Client) sendPacket(ctx context.Context, p sioPacket) error { + frames, err := encodePacket(p) + if err != nil { + return err + } + for _, f := range frames { + if f.Binary { + if err := c.eng.WriteBinary(ctx, f.Data); err != nil { + return err + } + continue + } + if err := c.eng.WriteMessage(ctx, f.Data); err != nil { + return err + } + } + return nil +} + +// Connect sends the namespace CONNECT packet and waits for the server CONNECT +// ack, then starts the read loop in a background goroutine. +func (c *Client) Connect(ctx context.Context) error { + if err := c.sendPacket(ctx, sioPacket{Type: sioConnect, Nsp: c.nsp}); err != nil { + return err + } + go c.readLoop(ctx) + select { + case <-c.connected: + return nil + case err := <-c.connErr: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *Client) readLoop(ctx context.Context) { + for { + pkt, err := c.eng.Read(ctx) + if err != nil { + c.dispatch("disconnect", []any{err.Error()}) + return + } + p, complete, derr := c.dec.add(pkt) + if derr != nil || !complete { + continue + } + c.handlePacket(ctx, p) + } +} + +func (c *Client) handlePacket(ctx context.Context, p sioPacket) { + switch p.Type { + case sioConnect: + select { + case <-c.connected: + default: + close(c.connected) + } + case sioConnectError: + select { + case c.connErr <- fmt.Errorf("wpstream: connect_error: %v", p.Data): + default: + } + case sioEvent, sioBinaryEvent: + if len(p.Data) == 0 { + return + } + event, _ := p.Data[0].(string) + args := p.Data[1:] + c.dispatchWithAck(ctx, event, args, p.ID) + case sioAck, sioBinaryAck: + if p.ID == nil { + return + } + c.mu.Lock() + ack := c.acks[*p.ID] + delete(c.acks, *p.ID) + c.mu.Unlock() + if ack != nil { + ack(p.Data) + } + case sioDisconnect: + c.dispatch("disconnect", nil) + } +} + +func (c *Client) dispatch(event string, args []any) { + c.mu.Lock() + hs := append([]func([]any){}, c.handlers[event]...) + c.mu.Unlock() + for _, h := range hs { + h(args) + } +} + +// dispatchWithAck delivers an event, invoking raw handlers (with the ack id) +// before plain handlers. The iostream layer registers $stream-write via OnRaw +// and sends the ack itself; nothing is auto-acked here. +func (c *Client) dispatchWithAck(ctx context.Context, event string, args []any, id *int) { + c.mu.Lock() + rhs := append([]func([]any, *int){}, c.rawHandlers[event]...) + c.mu.Unlock() + for _, h := range rhs { + h(args, id) + } + c.dispatch(event, args) + _ = ctx +} diff --git a/internal/wpstream/socketio_test.go b/internal/wpstream/socketio_test.go new file mode 100644 index 000000000..e896a3186 --- /dev/null +++ b/internal/wpstream/socketio_test.go @@ -0,0 +1,402 @@ +package wpstream + +import ( + "bytes" + "context" + "encoding/json/v2" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" +) + +// ── codec tests ────────────────────────────────────────────────────────────── + +func TestEncodeStringEvent(t *testing.T) { + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", Data: []any{"x", map[string]any{"a": float64(1)}}} + frames, err := encodePacket(p) + if err != nil { + t.Fatal(err) + } + if len(frames) != 1 || frames[0].Binary { + t.Fatalf("frames = %+v", frames) + } + if got := string(frames[0].Data); got != `2/wp-cli,["x",{"a":1}]` { + t.Errorf("encoded = %q", got) + } +} + +func TestEncodeEventWithAckID(t *testing.T) { + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", ID: intPtr(7), Data: []any{"ev"}} + frames, _ := encodePacket(p) + if got := string(frames[0].Data); got != `2/wp-cli,7["ev"]` { + t.Errorf("encoded = %q", got) + } +} + +func TestEncodeBinaryEvent(t *testing.T) { + chunk := []byte{0xde, 0xad} + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", ID: intPtr(3), + Data: []any{"$stream-write", "sid", binaryArg(chunk), "buffer"}} + frames, err := encodePacket(p) + if err != nil { + t.Fatal(err) + } + if len(frames) != 2 { + t.Fatalf("want 2 frames, got %d", len(frames)) + } + const prefix = `51-/wp-cli,3` + if !bytes.HasPrefix(frames[0].Data, []byte(prefix)) { + t.Fatalf("header = %q, want prefix %q", frames[0].Data, prefix) + } + var payload []any + if err := json.Unmarshal(frames[0].Data[len(prefix):], &payload); err != nil { + t.Fatalf("decode header payload: %v", err) + } + if len(payload) != 4 || payload[0] != "$stream-write" || payload[1] != "sid" || payload[3] != "buffer" { + t.Fatalf("header payload = %#v", payload) + } + placeholder, ok := payload[2].(map[string]any) + if !ok || len(placeholder) != 2 { + t.Fatalf("placeholder = %#v", payload[2]) + } + if marker, ok := placeholder["_placeholder"].(bool); !ok || !marker { + t.Errorf("placeholder marker = %#v", placeholder["_placeholder"]) + } + if num, ok := placeholder["num"].(float64); !ok || num != 0 { + t.Errorf("placeholder number = %#v", placeholder["num"]) + } + if !frames[1].Binary || !bytes.Equal(frames[1].Data, chunk) { + t.Errorf("attachment frame = %+v", frames[1]) + } +} + +func TestDecodeStringEvent(t *testing.T) { + d := newSioDecoder() + p, complete, err := d.add(Packet{Type: '4', Data: []byte(`2/wp-cli,["exit",{"exitCode":0}]`)}) + if err != nil || !complete { + t.Fatalf("complete=%v err=%v", complete, err) + } + if p.Type != sioEvent || p.Nsp != "/wp-cli" { + t.Errorf("packet = %+v", p) + } + if p.Data[0] != "exit" { + t.Errorf("event = %v", p.Data[0]) + } +} + +func TestDecodeBinaryEventReassembly(t *testing.T) { + d := newSioDecoder() + p, complete, err := d.add(Packet{Type: '4', + Data: []byte(`51-/wp-cli,["$stream-write","sid",{"_placeholder":true,"num":0},"buffer"]`)}) + if err != nil { + t.Fatal(err) + } + if complete { + t.Fatal("must wait for the binary attachment") + } + p, complete, err = d.add(Packet{Binary: true, Data: []byte{0x01, 0x02}}) + if err != nil || !complete { + t.Fatalf("complete=%v err=%v", complete, err) + } + if p.Data[0] != "$stream-write" { + t.Errorf("event = %v", p.Data[0]) + } + got, ok := p.Data[2].([]byte) + if !ok || !bytes.Equal(got, []byte{0x01, 0x02}) { + t.Errorf("reassembled attachment = %v (%T)", p.Data[2], p.Data[2]) + } +} + +// ── client tests ───────────────────────────────────────────────────────────── + +// fakeSocketIOServer builds a test HTTP server that: +// 1. answers the EIO4 polling handshake +// 2. accepts the websocket upgrade + 2probe/5 dance +// 3. runs fn(ctx, wsConn) for the server-side socket.io logic +func fakeSocketIOServer(t *testing.T, fn func(ctx context.Context, c *websocket.Conn)) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/socket.io/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("transport") == "polling" { + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = w.Write([]byte(`0{"sid":"abc","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}`)) + return + } + c, err := websocket.Accept(w, r, nil) + if err != nil { + t.Logf("ws accept: %v", err) + return + } + defer c.Close(websocket.StatusNormalClosure, "") + ctx := r.Context() + // EIO4 probe dance + _, probe, _ := c.Read(ctx) + if string(probe) != "2probe" { + t.Errorf("probe = %q", probe) + } + _ = c.Write(ctx, websocket.MessageText, []byte("3probe")) + _, up, _ := c.Read(ctx) + if string(up) != "5" { + t.Errorf("upgrade = %q", up) + } + fn(ctx, c) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// sendSIO writes a text Socket.IO packet wrapped in EIO "4" envelope. +func sendSIO(ctx context.Context, c *websocket.Conn, payload string) error { + return c.Write(ctx, websocket.MessageText, []byte("4"+payload)) +} + +// readSIOPacket reads one EIO text frame and strips the leading "4". +func readSIOPacket(ctx context.Context, c *websocket.Conn) (string, error) { + _, b, err := c.Read(ctx) + if err != nil { + return "", err + } + if len(b) == 0 || b[0] != '4' { + return "", fmt.Errorf("expected EIO message frame, got %q", b) + } + return string(b[1:]), nil +} + +func TestClientConnectAndEvent(t *testing.T) { + exitFired := make(chan float64, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT for /wp-cli + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Errorf("reading client CONNECT: %v", err) + return + } + if raw != "0/wp-cli," { + t.Errorf("client CONNECT = %q, want %q", raw, "0/wp-cli,") + } + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s1"}`) + + // Send EVENT: exit with exitCode 0 + _ = sendSIO(ctx, c, `2/wp-cli,["exit",{"exitCode":0}]`) + + // Hold the connection open briefly so the client can process. + time.Sleep(50 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.On("exit", func(args []any) { + if m, ok := args[0].(map[string]any); ok { + if code, ok := m["exitCode"].(float64); ok { + exitFired <- code + } + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case code := <-exitFired: + if code != 0 { + t.Errorf("exitCode = %v, want 0", code) + } + case <-time.After(time.Second): + t.Fatal("timeout: exit handler never fired") + } +} + +func TestClientOnRawReceivesAckID(t *testing.T) { + rawFired := make(chan *int, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s2"}`) + + // Send EVENT with ack id 42 + _ = sendSIO(ctx, c, `2/wp-cli,42["$stream-write","somearg"]`) + + time.Sleep(100 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.OnRaw("$stream-write", func(args []any, ackID *int) { + rawFired <- ackID + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case id := <-rawFired: + if id == nil { + t.Fatal("ackID is nil, expected 42") + } + if *id != 42 { + t.Errorf("ackID = %d, want 42", *id) + } + case <-time.After(time.Second): + t.Fatal("timeout: OnRaw handler never fired") + } +} + +// TestClientEmitAck verifies that Emit with an ack callback receives the reply. +func TestClientEmitAck(t *testing.T) { + ackReceived := make(chan []any, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s3"}`) + + // Read the Emit frame + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Errorf("reading emit: %v", err) + return + } + // Decode the ack id from the packet (e.g. "2/wp-cli,1["ping"]") + // Simple approach: just parse the ack from it. + // For test purposes decode enough to get the ack id. + d := newSioDecoder() + pkt, complete, derr := d.add(Packet{Type: eioMessage, Data: []byte(raw)}) + if derr != nil || !complete { + t.Errorf("decode emit: err=%v complete=%v", derr, complete) + return + } + if pkt.ID == nil { + t.Error("no ack id in emitted packet") + return + } + // Send ACK back: "3/wp-cli,<id>["pong"]" + ackPkt := fmt.Sprintf(`3/wp-cli,%d["pong"]`, *pkt.ID) + _ = sendSIO(ctx, c, ackPkt) + + time.Sleep(100 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + if err := cl.Emit(ctx, "ping", nil, func(args []any) { + ackReceived <- args + }); err != nil { + t.Fatalf("Emit: %v", err) + } + + select { + case args := <-ackReceived: + if len(args) == 0 || args[0] != "pong" { + t.Errorf("ack args = %v, want [pong]", args) + } + case <-time.After(time.Second): + t.Fatal("timeout: ack never received") + } +} + +// TestClientAckReply verifies that ackReply sends a proper ACK packet. +func TestClientAckReply(t *testing.T) { + ackRaw := make(chan string, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s4"}`) + + // Send an event with ack id 99 + _ = sendSIO(ctx, c, `2/wp-cli,99["greet","hello"]`) + + // Read the ACK reply + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Logf("reading ack reply: %v", err) + return + } + ackRaw <- raw + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.OnRaw("greet", func(args []any, ackID *int) { + if ackID != nil { + ctx := context.Background() + _ = cl.ackReply(ctx, *ackID, []any{"world"}) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case raw := <-ackRaw: + var data []any + // Parse the reply: "3/wp-cli,99["world"]" + d := newSioDecoder() + pkt, _, _ := d.add(Packet{Type: eioMessage, Data: []byte(raw)}) + if pkt.Type != sioAck { + t.Errorf("reply type = %d, want sioAck(%d)", pkt.Type, sioAck) + } + if pkt.ID == nil || *pkt.ID != 99 { + t.Errorf("reply ack id = %v, want 99", pkt.ID) + } + data = pkt.Data + if len(data) == 0 || data[0] != "world" { + t.Errorf("reply data = %v", data) + } + case <-time.After(time.Second): + t.Fatal("timeout: ack reply never received") + } +} diff --git a/internal/wpstream/testdata/fixture-server.js b/internal/wpstream/testdata/fixture-server.js new file mode 100644 index 000000000..3e1669dd4 --- /dev/null +++ b/internal/wpstream/testdata/fixture-server.js @@ -0,0 +1,54 @@ +// Minimal socket.io v4 server implementing a fake /wp-cli namespace for the +// Go wpstream e2e tests. Reads the cmd payload, streams scripted stdout via +// socket.io-stream, optionally kills the connection mid-stream (offset resume), +// then emits exit. Configured via env: +// PORT (required) — listen port +// SCRIPT_STDOUT — bytes to stream to stdout +// EXIT_CODE — exit code to emit (default 0) +// EXIT_MESSAGE — optional exit message +// KILL_AFTER — if set, destroy the connection after N stdout bytes +// KILL_TIMES — how many times to kill (default: 1 when KILL_AFTER set, +// else 0); use KILL_TIMES=2 to force two reconnects +const http = require('http'); +const { Server } = require('socket.io'); +const ss = require('socket.io-stream'); + +const server = http.createServer(); +const io = new Server(server, { /* default EIO4 */ }); + +const STDOUT = Buffer.from(process.env.SCRIPT_STDOUT || ''); +const EXIT_CODE = Number(process.env.EXIT_CODE || 0); +const EXIT_MESSAGE = process.env.EXIT_MESSAGE || ''; +const KILL_AFTER = process.env.KILL_AFTER ? Number(process.env.KILL_AFTER) : -1; + +// Default KILL_TIMES to 1 when KILL_AFTER is set and KILL_TIMES is not +// explicitly provided, so the existing single-kill test is unaffected. +let killsRemaining = KILL_AFTER >= 0 + ? (process.env.KILL_TIMES !== undefined ? Number(process.env.KILL_TIMES) : 1) + : 0; + +io.of('/wp-cli').on('connection', socket => { + ss(socket).on('cmd', (data, stdinStream, stdoutStream) => { + const offset = data.offset || 0; + let slice = STDOUT.slice(offset); + + if (KILL_AFTER >= 0 && killsRemaining > 0 && slice.length > KILL_AFTER) { + killsRemaining--; + stdoutStream.write(slice.slice(0, KILL_AFTER)); + // Drop the connection mid-stream to force a Go-side reconnect+resume. + setImmediate(() => socket.client.conn.close()); + return; + } + + stdoutStream.end(slice); + // Drain any stdin the client sends (echo not required for these tests). + stdinStream.resume(); + stdoutStream.on('end', () => { + socket.emit('exit', { exitCode: EXIT_CODE, message: EXIT_MESSAGE }); + }); + }); +}); + +server.listen(Number(process.env.PORT), '127.0.0.1', () => { + process.stdout.write('LISTENING\n'); // handshake for the Go test +}); diff --git a/make.ps1 b/make.ps1 new file mode 100644 index 000000000..656ae68d8 --- /dev/null +++ b/make.ps1 @@ -0,0 +1,155 @@ +<# +.SYNOPSIS + PowerShell port of the Makefile for building/testing vip-next on native Windows. + (On macOS/Linux/WSL use the Makefile: `make build`, `make test`, ...) + +.USAGE + powershell -ExecutionPolicy Bypass -File .\make.ps1 <target> + # or, in a session that already allows scripts: + .\make.ps1 build + + Targets: + build Build bin\vip-next.exe (version-stamped) + bundle go-search-replace.exe + search-replace-bin Bundle the host go-search-replace binary next to vip-next (called by build) + test go test ./... (the whole suite) + test-parity go test -tags=parity ./internal/parity/... + lint go vet ./... + tidy go mod tidy + tidy-gql Regenerate internal/gql/generated.go via genqlient + verify-gql-stale Fail if generated.go is stale vs schema/operations (working tree untouched) + clean Remove bin\ + + Notes: + * Requires Go 1.27. `encoding/json/v2` is part of the standard library. + * If running scripts is blocked, prefix with: powershell -ExecutionPolicy Bypass -File .\make.ps1 ... +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('build', 'search-replace-bin', 'test', 'test-parity', 'lint', 'tidy', 'tidy-gql', 'verify-gql-stale', 'clean', 'help')] + [string]$Target = 'build' +) + +$ErrorActionPreference = 'Stop' + +# --- config (mirrors the Makefile vars) --- +$GO = if ($env:GO) { $env:GO } else { 'go' } +$BinDir = 'bin' +$BinName = 'vip-next.exe' +$BinPath = Join-Path $BinDir $BinName + +# Run a Go command and stop on a non-zero exit (PowerShell doesn't do this for native exes by default). +function Invoke-Go { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GoArgs) + Write-Host "+ $GO $($GoArgs -join ' ')" -ForegroundColor DarkGray + & $GO @GoArgs + if ($LASTEXITCODE -ne 0) { throw "go $($GoArgs[0]) failed (exit $LASTEXITCODE)" } +} + +# LDFLAGS: version/commit from git, with the same fallbacks as the Makefile. +function Get-LdFlags { + # Windows PowerShell 5.1 promotes native git stderr ("not a git repository") to a + # terminating error under this script's $ErrorActionPreference='Stop' -- even with 2>$null -- + # which kills the intended dev/unknown fallback when building outside a git checkout + # (e.g. from a source tarball). Scope the preference down for the git probes below. + $ErrorActionPreference = 'SilentlyContinue' + $version = (& git describe --tags --always --dirty 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { $version = 'dev' } + $commit = (& git rev-parse --short HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($commit)) { $commit = 'unknown' } + $pkg = 'github.com/Automattic/vip/internal/version' + return "-s -w -X $pkg.Version=$version -X $pkg.Commit=$commit" +} + +function Target-Build { + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + $env:CGO_ENABLED = '0' + $ldflags = Get-LdFlags + Invoke-Go build '-buildvcs=false' '-trimpath' '-ldflags' $ldflags '-o' $BinPath './cmd/vip-next' + Write-Host "built $BinPath" -ForegroundColor Green + Target-SearchReplaceBin +} + +# Bundle the host's go-search-replace binary next to vip-next so `import sql` +# (--search-replace) and `dev-env sync sql` resolve it without a runtime download. +# (Not needed for the dev-env hosts feature, but kept for Makefile parity.) +function Target-SearchReplaceBin { + $os = (& $GO env GOOS).Trim() + $arch = (& $GO env GOARCH).Trim() + $fixture = switch ("$os/$arch") { + 'darwin/arm64' { 'go-search-replace-test-darwin-arm64' } + 'darwin/amd64' { 'go-search-replace-test-darwin-x64' } + 'linux/amd64' { 'go-search-replace-test-linux-x64' } + 'windows/amd64' { 'go-search-replace-test-win32-x64.exe' } + default { $null } + } + if (-not $fixture) { + Write-Host "no bundled go-search-replace for $os/$arch; set VIP_SEARCH_REPLACE_BIN to use sync/search-replace" -ForegroundColor Yellow + return + } + $src = Join-Path '__fixtures__/search-replace-binaries' $fixture + $dest = Join-Path $BinDir ('go-search-replace' + $(if ($os -eq 'windows') { '.exe' } else { '' })) + if (Test-Path $src) { + Copy-Item -Force $src $dest + Write-Host "bundled go-search-replace -> $dest" -ForegroundColor Green + } + else { + Write-Host "fixture $src missing; set VIP_SEARCH_REPLACE_BIN to use sync/search-replace" -ForegroundColor Yellow + } +} + +function Target-Test { Invoke-Go test './...' } +function Target-TestParity { Invoke-Go test '-tags=parity' './internal/parity/...' } +function Target-Lint { Invoke-Go vet './...' } +function Target-Tidy { Invoke-Go mod tidy } +function Target-Clean { if (Test-Path $BinDir) { Remove-Item -Recurse -Force $BinDir }; Write-Host "cleaned $BinDir" } + +# Regenerate internal/gql/generated.go from schema.gql + operations/*.graphql. +function Target-TidyGql { + Push-Location internal/gql + try { Invoke-Go run 'github.com/Khan/genqlient' } + finally { Pop-Location } +} + +# Fail if internal/gql/generated.go is stale. Like the Makefile, this NEVER leaves +# the on-disk file altered: it saves the contributor's copy, runs genqlient (which +# overwrites generated.go), compares, and ALWAYS restores the saved copy. +function Target-VerifyGqlStale { + Push-Location internal/gql + $stash = [System.IO.Path]::GetTempFileName() + try { + Copy-Item -Force 'generated.go' $stash + Invoke-Go run 'github.com/Khan/genqlient' + $same = $null -eq (Compare-Object (Get-Content $stash) (Get-Content 'generated.go')) + if ($same) { + Write-Host 'internal/gql/generated.go is up to date' -ForegroundColor Green + } + else { + Write-Host '' + Write-Host 'ERROR: internal/gql/generated.go is stale relative to schema.gql / operations/*.graphql.' -ForegroundColor Red + Write-Host "Run '.\make.ps1 tidy-gql' and commit the regenerated file." + throw 'generated.go is stale' + } + } + finally { + Copy-Item -Force $stash 'generated.go' # always restore the contributor's copy + Remove-Item -Force $stash -ErrorAction SilentlyContinue + Pop-Location + } +} + +function Target-Help { Get-Help $PSCommandPath -Detailed } + +switch ($Target) { + 'build' { Target-Build } + 'search-replace-bin' { Target-SearchReplaceBin } + 'test' { Target-Test } + 'test-parity' { Target-TestParity } + 'lint' { Target-Lint } + 'tidy' { Target-Tidy } + 'tidy-gql' { Target-TidyGql } + 'verify-gql-stale' { Target-VerifyGqlStale } + 'clean' { Target-Clean } + 'help' { Target-Help } +} diff --git a/scripts/catalog_script_test.go b/scripts/catalog_script_test.go new file mode 100644 index 000000000..21ceeaefb --- /dev/null +++ b/scripts/catalog_script_test.go @@ -0,0 +1,123 @@ +// Package scripts_test exercises the repo's shell tooling. +// +// vip-next-command-catalog.sh is a cutover gate: it walks every vip-next +// command and reports pass/fail. Its `run_expected_failure` helper documents +// calls that MUST be rejected — most importantly `vip sync` targeting +// production. A gate that cannot fail is not a gate, so these tests drive the +// script from both sides: a stub CLI that rejects the call (the documented +// behaviour) and a stub CLI that accepts it (the regression the gate exists to +// catch). +package scripts_test + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// catalogStub writes a fake vip-next. In "reject-sync" mode it exits 1 for the +// platform `sync` invocation (what the real CLI does today: production is not a +// valid sync target); in "accept-everything" mode it exits 0 for every call, +// simulating a future regression where vip-next starts ACCEPTING a sync into +// production. +func catalogStub(t *testing.T, mode string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "vip-next-stub") + + body := `#!/usr/bin/env bash +# Fake vip-next for catalog-script tests. +mode="` + mode + `" +args="$*" +if [[ "$mode" == "reject-sync" ]]; then + # The platform ` + "`sync`" + ` catalog entry is the only bare "sync" call; + # "dev-env sync --help" and "dev-env sync sql" must stay successful. + if [[ " $args " == *" sync "* || " $args " == *" sync" ]]; then + if [[ "$args" != *"--help"* && "$args" != *"dev-env"* ]]; then + echo "sync into production is not permitted" >&2 + exit 1 + fi + fi +fi +exit 0 +` + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { // #nosec G306 + t.Fatalf("write stub: %v", err) + } + return path +} + +// runCatalog executes the catalog script with only the read-only and +// expected-failure gates open. No mutating or destructive entry can run. +func runCatalog(t *testing.T, stub string) (string, int) { + t.Helper() + cmd := exec.Command("bash", "./vip-next-command-catalog.sh") + cmd.Env = append(os.Environ(), + "VIP_NEXT="+stub, + "RUN=1", + "ALLOW_EXPECTED_FAILURES=1", + "ALLOW_INTERACTIVE=0", + "ALLOW_MUTATIONS=0", + "ALLOW_DESTRUCTIVE=0", + ) + out, err := cmd.CombinedOutput() + code := 0 + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("run catalog script: %v\n%s", err, out) + } + code = exitErr.ExitCode() + } + return string(out), code +} + +func requireBash(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("catalog script is bash-only") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } +} + +// TestCatalogExpectedFailureAcceptsARejection is the documented happy path: the +// CLI refuses the invalid call, the script records an expected failure, and the +// suite still succeeds. +func TestCatalogExpectedFailureAcceptsARejection(t *testing.T) { + requireBash(t) + + out, code := runCatalog(t, catalogStub(t, "reject-sync")) + + if code != 0 { + t.Fatalf("catalog exit = %d, want 0 when the expected failure actually failed\n%s", code, out) + } + if !strings.Contains(out, "expected-failures=1") { + t.Errorf("summary did not record the expected failure:\n%s", out) + } +} + +// TestCatalogExpectedFailureFailsWhenTheCommandSucceeds is the reason this file +// exists. `run_expected_failure` used to treat BOTH a zero and a non-zero exit +// as success, so the gate would report PASS if vip-next started accepting a +// sync into production. A zero exit MUST fail the suite. +func TestCatalogExpectedFailureFailsWhenTheCommandSucceeds(t *testing.T) { + requireBash(t) + + out, code := runCatalog(t, catalogStub(t, "accept-everything")) + + if code == 0 { + t.Fatalf("catalog exit = 0; an expected-failure command that SUCCEEDED must fail the suite\n%s", out) + } + if !strings.Contains(out, "expected-failure command unexpectedly succeeded") { + t.Errorf("output does not explain why the suite failed:\n%s", out) + } + if !strings.Contains(out, "expected-failures=0") { + t.Errorf("a zero exit must not be counted as an expected failure:\n%s", out) + } +} diff --git a/scripts/vip-next-command-catalog.sh b/scripts/vip-next-command-catalog.sh new file mode 100755 index 000000000..a2e9f5019 --- /dev/null +++ b/scripts/vip-next-command-catalog.sh @@ -0,0 +1,506 @@ +#!/usr/bin/env bash +# Catalog of every vip-next command and subcommand discovered from live help. +# +# Safe by default: running this file only prints shell-escaped commands. +# Execution gates: +# RUN=1 execute read-only commands +# RUN=1 ALLOW_INTERACTIVE=1 execute interactive commands +# RUN=1 ALLOW_MUTATIONS=1 execute mutating commands +# RUN=1 ALLOW_MUTATIONS=1 ALLOW_DESTRUCTIVE=1 execute destructive commands +# RUN=1 ALLOW_INTERACTIVE=1 ALLOW_MUTATIONS=1 ALLOW_DESTRUCTIVE=1 +# execute destructive interactive commands +# RUN=1 ALLOW_EXPECTED_FAILURES=1 execute documented invalid-target calls +# +# Mutating and destructive calls target production or local Docker state. Review +# every placeholder and command before enabling any execution gate. + +set -euo pipefail + +VIP_NEXT="${VIP_NEXT:-./bin/vip-next}" +APP_ALIAS="@example-app.production" +DEV_ENV_SLUG="cutover-test" + +# Replace these values before opting into execution. +DEPLOY_ARCHIVE="${DEPLOY_ARCHIVE:-./cutover-test-app.zip}" +APP_LOOKUP="${APP_LOOKUP:-3453}" +SQL_FILE="${SQL_FILE:-./cutover-test.sql}" +MEDIA_ARCHIVE="${MEDIA_ARCHIVE:-./cutover-test-media.zip}" +MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-./cutover-test-media}" +SEARCH_REPLACE_FILE="${SEARCH_REPLACE_FILE:-$SQL_FILE}" +SEARCH_REPLACE_PAIR="${SEARCH_REPLACE_PAIR:-https://example.invalid,https://cutover-test.vipdev.site}" +PURGE_URL="${PURGE_URL:-https://example.invalid/}" +ENVVAR_NAME="${ENVVAR_NAME:-CUTOVER_TEST_VAR}" +ENVVAR_VALUE="${ENVVAR_VALUE:-cutover-test-value}" +ENVVAR_VALUE_FILE="${ENVVAR_VALUE_FILE:-./cutover-test-envvar.txt}" +SOFTWARE_COMPONENT="${SOFTWARE_COMPONENT:-php}" +SOFTWARE_VERSION="${SOFTWARE_VERSION:-8.3}" +DEV_ENV_PHP_VERSION="${DEV_ENV_PHP_VERSION:-8.3}" +DEV_ENV_TITLE="${DEV_ENV_TITLE:-Cutover Test}" +DEFENSIVE_ENABLED="${DEFENSIVE_ENABLED:-true}" +DEFENSIVE_CHALLENGE_TYPE="${DEFENSIVE_CHALLENGE_TYPE:-1}" +DEFENSIVE_ABSOLUTE_THRESHOLD="${DEFENSIVE_ABSOLUTE_THRESHOLD:-100}" +DEFENSIVE_PERCENT_THRESHOLD="${DEFENSIVE_PERCENT_THRESHOLD:-50}" + +RUN="${RUN:-0}" +ALLOW_INTERACTIVE="${ALLOW_INTERACTIVE:-0}" +ALLOW_MUTATIONS="${ALLOW_MUTATIONS:-0}" +ALLOW_DESTRUCTIVE="${ALLOW_DESTRUCTIVE:-0}" +ALLOW_EXPECTED_FAILURES="${ALLOW_EXPECTED_FAILURES:-0}" + +SUITE_PASSED=0 +SUITE_FAILED=0 +SUITE_SKIPPED=0 +SUITE_EXPECTED_FAILURES=0 +FAILED_STATUSES=() +FAILED_COMMANDS=() + +print_command() { + printf '+ ' + printf '%q ' "$@" + printf '\n' +} + +run_command() { + local enabled="$1" + shift + print_command "$@" + if [[ "$enabled" != "1" ]]; then + SUITE_SKIPPED=$((SUITE_SKIPPED + 1)) + return 0 + fi + if "$@"; then + SUITE_PASSED=$((SUITE_PASSED + 1)) + else + local status=$? + local rendered + printf -v rendered '%q ' "$@" + rendered="${rendered% }" + SUITE_FAILED=$((SUITE_FAILED + 1)) + FAILED_STATUSES[$SUITE_FAILED]="$status" + FAILED_COMMANDS[$SUITE_FAILED]="$rendered" + printf '# command failed (exit %d): %s\n' "$status" "$rendered" >&2 + fi + return 0 +} + +print_failure_ledger() { + if ((SUITE_FAILED == 0)); then + return 0 + fi + printf '# failed commands:\n' + local index + for ((index = 1; index <= SUITE_FAILED; index++)); do + printf '# [%d/%d] exit=%d\n' \ + "$index" "$SUITE_FAILED" "${FAILED_STATUSES[$index]}" + printf '# command: %s\n' "${FAILED_COMMANDS[$index]}" + done +} + +run_readonly() { + local enabled=0 + if [[ "$RUN" == "1" ]]; then + enabled=1 + fi + run_command "$enabled" "$@" +} + +run_interactive() { + local enabled=0 + if [[ "$RUN" == "1" && "$ALLOW_INTERACTIVE" == "1" ]]; then + enabled=1 + fi + run_command "$enabled" "$@" +} + +run_mutation() { + local enabled=0 + if [[ "$RUN" == "1" && "$ALLOW_MUTATIONS" == "1" ]]; then + enabled=1 + fi + run_command "$enabled" "$@" +} + +run_destructive() { + local enabled=0 + if [[ "$RUN" == "1" && "$ALLOW_MUTATIONS" == "1" && "$ALLOW_DESTRUCTIVE" == "1" ]]; then + enabled=1 + fi + run_command "$enabled" "$@" +} + +run_destructive_interactive() { + local enabled=0 + if [[ "$RUN" == "1" && "$ALLOW_INTERACTIVE" == "1" && "$ALLOW_MUTATIONS" == "1" && "$ALLOW_DESTRUCTIVE" == "1" ]]; then + enabled=1 + fi + run_command "$enabled" "$@" +} + +# Runs a call that is DOCUMENTED to be rejected — e.g. platform `sync` targeting +# production, which has no valid child target. +# +# A zero exit is a FAILURE, not a pass. Treating both outcomes as success made +# this gate unfalsifiable: it would have reported PASS if vip-next ever started +# ACCEPTING a sync into production, which is precisely the regression the entry +# exists to catch. Regression tests: scripts/catalog_script_test.go. +run_expected_failure() { + local enabled=0 + if [[ "$RUN" == "1" && "$ALLOW_EXPECTED_FAILURES" == "1" ]]; then + enabled=1 + fi + print_command "$@" + if [[ "$enabled" != "1" ]]; then + SUITE_SKIPPED=$((SUITE_SKIPPED + 1)) + return 0 + fi + set +e + "$@" + local status=$? + set -e + printf '# expected-failure exit status: %d\n' "$status" + if ((status == 0)); then + local rendered + printf -v rendered '%q ' "$@" + rendered="${rendered% }" + SUITE_FAILED=$((SUITE_FAILED + 1)) + FAILED_STATUSES[$SUITE_FAILED]="$status" + FAILED_COMMANDS[$SUITE_FAILED]="$rendered" + printf '# expected-failure command unexpectedly succeeded (exit 0): %s\n' "$rendered" >&2 + printf '# this call is documented as invalid; a zero exit means vip-next now accepts it\n' >&2 + return 0 + fi + SUITE_EXPECTED_FAILURES=$((SUITE_EXPECTED_FAILURES + 1)) + return 0 +} + +# COMMAND: logout +# Deletes the locally stored authentication token. Destructive because later +# authenticated calls will fail until login succeeds. +run_destructive "$VIP_NEXT" logout + +# COMMAND: login +# Opens the interactive Personal Access Token login flow and stores the token. +run_interactive "$VIP_NEXT" login + +# COMMAND: whoami +# Prints details for the currently authenticated user. +run_readonly "$VIP_NEXT" whoami + +# COMMAND: <root> +# Displays the root help and top-level command tree. +run_readonly "$VIP_NEXT" --help + +# COMMAND: app +# Displays help for application discovery, lookup, and deploy operations. +run_readonly "$VIP_NEXT" app --help + +# COMMAND: app <name> +# Retrieves the configured application and its environments through wildcard lookup. +run_readonly "$VIP_NEXT" app "$APP_LOOKUP" --format json + +# COMMAND: app list +# Lists applications visible to the authenticated user. +run_readonly "$VIP_NEXT" app list --format json + +# COMMAND: app deploy +# Uploads and deploys an application archive to production. Requires a valid +# WPVIP_DEPLOY_TOKEN in the environment and all destructive gates. +run_destructive "$VIP_NEXT" "$APP_ALIAS" app deploy "$DEPLOY_ARCHIVE" --skip-confirmation + +# COMMAND: app deploy validate +# Validates the application archive locally without deploying it. +run_readonly "$VIP_NEXT" app deploy validate "$DEPLOY_ARCHIVE" + +# COMMAND: backup +# Displays help for environment backup commands. +run_readonly "$VIP_NEXT" backup --help + +# COMMAND: backup db +# Starts or follows a production database backup job. +run_mutation "$VIP_NEXT" "$APP_ALIAS" backup db + +# COMMAND: cache +# Displays help for edge-cache operations. +run_readonly "$VIP_NEXT" cache --help + +# COMMAND: cache purge-url +# Purges one URL from the production edge cache. +run_mutation "$VIP_NEXT" "$APP_ALIAS" cache purge-url "$PURGE_URL" + +# COMMAND: completion +# Displays help for shell completion generators. +run_readonly "$VIP_NEXT" completion --help + +# COMMAND: completion bash +# Writes a Bash completion script to standard output. +run_readonly "$VIP_NEXT" completion bash + +# COMMAND: completion fish +# Writes a Fish completion script to standard output. +run_readonly "$VIP_NEXT" completion fish + +# COMMAND: completion powershell +# Writes a PowerShell completion script to standard output. +run_readonly "$VIP_NEXT" completion powershell + +# COMMAND: completion zsh +# Writes a Zsh completion script to standard output. +run_readonly "$VIP_NEXT" completion zsh + +# COMMAND: config +# Displays help for environment configuration commands. +run_readonly "$VIP_NEXT" config --help + +# COMMAND: config envvar +# Displays help for platform environment-variable commands. +run_readonly "$VIP_NEXT" config envvar --help + +# COMMAND: config envvar delete +# Permanently deletes one production environment variable. +run_destructive "$VIP_NEXT" "$APP_ALIAS" config envvar delete "$ENVVAR_NAME" --skip-confirmation + +# COMMAND: config envvar get +# Retrieves one production environment-variable value; output may be sensitive. +run_readonly "$VIP_NEXT" "$APP_ALIAS" config envvar get "$ENVVAR_NAME" + +# COMMAND: config envvar get-all +# Retrieves all production environment variables and values; output is sensitive. +run_readonly "$VIP_NEXT" "$APP_ALIAS" config envvar get-all --format json + +# COMMAND: config envvar list +# Lists production environment-variable names without their values. +run_readonly "$VIP_NEXT" "$APP_ALIAS" config envvar list --format json + +# COMMAND: config envvar set +# Sets one production environment variable from a local UTF-8 file. +run_mutation "$VIP_NEXT" "$APP_ALIAS" config envvar set "$ENVVAR_NAME" --from-file "$ENVVAR_VALUE_FILE" --skip-confirmation + +# COMMAND: config software +# Displays help for software-version commands. +run_readonly "$VIP_NEXT" config software --help + +# COMMAND: config software get +# Retrieves the selected production software component and available versions. +run_readonly "$VIP_NEXT" "$APP_ALIAS" config software get "$SOFTWARE_COMPONENT" --include available_versions --format json + +# COMMAND: config software update +# Updates a production software component to the configured version. +run_mutation "$VIP_NEXT" "$APP_ALIAS" config software update "$SOFTWARE_COMPONENT" "$SOFTWARE_VERSION" --yes + +# COMMAND: db +# Displays help for database-access commands. +run_readonly "$VIP_NEXT" db --help + +# COMMAND: db phpmyadmin +# Enables or refreshes production phpMyAdmin access and prints its read-only URL. +run_mutation "$VIP_NEXT" "$APP_ALIAS" db phpmyadmin --print + +# COMMAND: defensive-mode +# Displays help for WAF defensive-mode operations. +run_readonly "$VIP_NEXT" defensive-mode --help + +# Step-up auth and the three defensive-mode calls below. +# +# --non-interactive does NOT suppress step-up: whether a mutation needs +# browser verification is the server's call, and no client flag can waive it. +# What it does is make an unsatisfiable challenge fail immediately (exit 1, +# "Step-up verification is required for <op>, but this is a non-interactive +# session") instead of printing a URL and polling until the session expires. +# --skip-confirmation is a separate thing again: it waives this CLI's own +# production prompt, not the server's step-up. +# +# So these three succeed only when the environment does not require step-up for +# the mutation, or when an interactive approval is still cached from an earlier +# run. To complete a challenge from here, drop --non-interactive (a TTY, opens a +# browser) or add --rechallenge-wait / VIP_RECHALLENGE_WAIT=1 to print the URL +# and block while you approve on another device. + +# COMMAND: defensive-mode configure +# Updates the production defensive-mode configuration. +run_mutation "$VIP_NEXT" "$APP_ALIAS" defensive-mode configure \ + --enabled "$DEFENSIVE_ENABLED" \ + --challenge-type "$DEFENSIVE_CHALLENGE_TYPE" \ + --connection-threshold-absolute "$DEFENSIVE_ABSOLUTE_THRESHOLD" \ + --connection-threshold-percentage "$DEFENSIVE_PERCENT_THRESHOLD" \ + --non-interactive \ + --skip-confirmation + +# COMMAND: defensive-mode disable +# Disables production defensive mode. +run_mutation "$VIP_NEXT" "$APP_ALIAS" defensive-mode disable --non-interactive --skip-confirmation + +# COMMAND: defensive-mode enable +# Enables production defensive mode. +run_mutation "$VIP_NEXT" "$APP_ALIAS" defensive-mode enable --non-interactive --skip-confirmation + +# COMMAND: dev-env +# Displays help for local development-environment commands. +run_readonly "$VIP_NEXT" dev-env --help + +# COMMAND: dev-env create +# Creates cutover-test locally without a wizard or automatic start. +run_mutation "$VIP_NEXT" dev-env create --slug "$DEV_ENV_SLUG" --title "$DEV_ENV_TITLE" --start=false --non-interactive + +# COMMAND: dev-env destroy +# Removes cutover-test and its local data. +run_destructive "$VIP_NEXT" dev-env destroy --slug "$DEV_ENV_SLUG" --yes + +# COMMAND: dev-env create +# Creates cutover-test locally without a wizard or automatic start. +run_mutation "$VIP_NEXT" dev-env create --slug "$DEV_ENV_SLUG" --title "$DEV_ENV_TITLE" --start=true --non-interactive + +# COMMAND: dev-env envvar +# Displays help for local environment-variable commands. +run_readonly "$VIP_NEXT" dev-env envvar --help + +# COMMAND: dev-env envvar set +# Sets one variable in cutover-test using a bounded positional value. +run_mutation "$VIP_NEXT" dev-env envvar set "$ENVVAR_NAME" "$ENVVAR_VALUE" --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env envvar delete +# Deletes one variable from cutover-test. +run_destructive "$VIP_NEXT" dev-env envvar delete "$ENVVAR_NAME" --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env envvar get +# Retrieves one variable from cutover-test. +run_readonly "$VIP_NEXT" dev-env envvar get "$ENVVAR_NAME" --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env envvar get-all +# Retrieves every variable and value from cutover-test. +run_readonly "$VIP_NEXT" dev-env envvar get-all --slug "$DEV_ENV_SLUG" --format json + +# COMMAND: dev-env envvar list +# Lists variable names stored for cutover-test. +run_readonly "$VIP_NEXT" dev-env envvar list --slug "$DEV_ENV_SLUG" --format json + + +# COMMAND: dev-env exec +# Runs a read-only WP-CLI home-option lookup inside cutover-test. +run_readonly "$VIP_NEXT" dev-env exec --slug "$DEV_ENV_SLUG" -- wp option get home + +# COMMAND: dev-env import +# Displays help for local import commands. +run_readonly "$VIP_NEXT" dev-env import --help + +# COMMAND: dev-env import media +# Copies a local media directory into cutover-test. +run_destructive "$VIP_NEXT" dev-env import media "$MEDIA_DIRECTORY" --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env import sql +# Replaces the cutover-test database from a local SQL file. +run_destructive "$VIP_NEXT" dev-env import sql "$SQL_FILE" --slug "$DEV_ENV_SLUG" --quiet + +# COMMAND: dev-env info +# Prints information about cutover-test. +run_readonly "$VIP_NEXT" dev-env info --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env list +# Lists all local development environments. +run_readonly "$VIP_NEXT" dev-env list + +# COMMAND: dev-env logs +# Prints current PHP service logs for cutover-test without following them. +run_readonly "$VIP_NEXT" dev-env logs --slug "$DEV_ENV_SLUG" --service php + +# COMMAND: dev-env purge +# Removes every local VIP development environment, not only cutover-test. +run_destructive "$VIP_NEXT" dev-env purge --yes + +# COMMAND: dev-env shell +# Runs a bounded pwd command in the cutover-test PHP service shell. +run_readonly "$VIP_NEXT" dev-env shell --slug "$DEV_ENV_SLUG" --service php -- pwd + +# COMMAND: dev-env start +# Starts cutover-test while skipping confirmation prompts. +run_mutation "$VIP_NEXT" dev-env start --slug "$DEV_ENV_SLUG" --skip-confirmation + +# COMMAND: dev-env stop +# Stops cutover-test. +run_mutation "$VIP_NEXT" dev-env stop --slug "$DEV_ENV_SLUG" + +# COMMAND: dev-env sync +# Displays help for platform-to-local synchronization. +run_readonly "$VIP_NEXT" dev-env sync --help + +# COMMAND: dev-env sync sql +# Replaces the cutover-test database from the selected production environment; +# unresolved multisite mappings fail with recovery flags instead of prompting. +run_destructive "$VIP_NEXT" "$APP_ALIAS" dev-env sync sql --slug "$DEV_ENV_SLUG" --force --non-interactive + +# COMMAND: dev-env update +# Updates cutover-test to the configured PHP version without opening its wizard. +run_mutation "$VIP_NEXT" dev-env update --slug "$DEV_ENV_SLUG" --php "$DEV_ENV_PHP_VERSION" --non-interactive + +# COMMAND: export +# Displays help for export commands. +run_readonly "$VIP_NEXT" export --help + +# COMMAND: export sql +# Creates or refreshes the production export job but skips the local download. +run_mutation "$VIP_NEXT" "$APP_ALIAS" export sql --skip-download + +# COMMAND: help +# Displays root command help through Cobra's explicit help command. +run_readonly "$VIP_NEXT" help + +# COMMAND: import +# Displays help for platform import and validation commands. +run_readonly "$VIP_NEXT" import --help + +# COMMAND: import media +# Imports a media archive into production. +run_destructive "$VIP_NEXT" "$APP_ALIAS" import media "$MEDIA_ARCHIVE" --skip-confirmation + +# COMMAND: import media abort +# Aborts the currently running production media import. +run_mutation "$VIP_NEXT" "$APP_ALIAS" import media abort --skip-confirmation + +# COMMAND: import media status +# Retrieves production media-import status without downloading an error log. +run_readonly "$VIP_NEXT" "$APP_ALIAS" import media status --saveErrorLog=false + +# COMMAND: import sql +# Replaces the production database from a SQL file. This is intentionally behind +# every destructive gate and may still prompt for command-specific confirmation. +run_destructive_interactive "$VIP_NEXT" "$APP_ALIAS" import sql "$SQL_FILE" + +# COMMAND: import sql status +# Retrieves the latest production SQL-import status. +run_readonly "$VIP_NEXT" "$APP_ALIAS" import sql status + +# COMMAND: import validate-files +# Validates a local media directory against VIP import constraints. +run_readonly "$VIP_NEXT" import validate-files "$MEDIA_DIRECTORY" + +# COMMAND: import validate-sql +# Validates a local SQL file for unsupported statements. +run_readonly "$VIP_NEXT" import validate-sql "$SQL_FILE" + +# COMMAND: logs +# Retrieves ten production runtime log entries as JSON without following. +run_readonly "$VIP_NEXT" "$APP_ALIAS" logs --limit 10 --format json + +# COMMAND: search-replace +# Streams a local SQL search-replace result to standard output without changing files. +run_readonly "$VIP_NEXT" search-replace "$SEARCH_REPLACE_FILE" --search-replace "$SEARCH_REPLACE_PAIR" + +# COMMAND: slowlogs +# Retrieves ten production MySQL slow-log entries as JSON. +run_readonly "$VIP_NEXT" "$APP_ALIAS" slowlogs --limit 10 --format json + +# COMMAND: sync +# Platform sync requires a child target, so production is intentionally invalid. +# The exact required alias is retained and execution needs ALLOW_EXPECTED_FAILURES=1. +run_expected_failure "$VIP_NEXT" "$APP_ALIAS" sync --skip-confirmation + +# COMMAND: wp +# Runs a bounded read-only WP-CLI lookup in production. --yes is extracted before +# the raw WP-CLI argument stream, as required by vip-next routing. +run_readonly "$VIP_NEXT" "$APP_ALIAS" --yes -- wp option get home + +printf '# suite summary: passed=%d failed=%d skipped=%d expected-failures=%d\n' \ + "$SUITE_PASSED" "$SUITE_FAILED" "$SUITE_SKIPPED" "$SUITE_EXPECTED_FAILURES" +print_failure_ledger +if ((SUITE_FAILED > 0)); then + exit 1 +fi diff --git a/testdata/parity-local/app-get-csv.yaml b/testdata/parity-local/app-get-csv.yaml new file mode 100644 index 000000000..e12e57765 --- /dev/null +++ b/testdata/parity-local/app-get-csv.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-get-csv +description: Compare Node and Go CSV app metadata for the discovered local Parker app. +argv: ['app', '{{app_id}}', '--format=csv'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/app-get-json.yaml b/testdata/parity-local/app-get-json.yaml new file mode 100644 index 000000000..b89c83b65 --- /dev/null +++ b/testdata/parity-local/app-get-json.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-get-json +description: Compare Node and Go JSON app metadata for the discovered local Parker app. +argv: ['app', '{{app_id}}', '--format=json'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/app-get-table.yaml b/testdata/parity-local/app-get-table.yaml new file mode 100644 index 000000000..78b7b9d20 --- /dev/null +++ b/testdata/parity-local/app-get-table.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-get-table +description: Compare Node and Go table app metadata for the discovered local Parker app. +argv: ['app', '{{app_id}}'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/app-list-csv.yaml b/testdata/parity-local/app-list-csv.yaml new file mode 100644 index 000000000..edd500e6b --- /dev/null +++ b/testdata/parity-local/app-list-csv.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-list-csv +description: Compare Node and Go CSV app-list output for local Parker user 10000. +argv: ['app', 'list', '--format=csv'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/app-list-json.yaml b/testdata/parity-local/app-list-json.yaml new file mode 100644 index 000000000..87d906e9f --- /dev/null +++ b/testdata/parity-local/app-list-json.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-list-json +description: Compare Node and Go JSON app-list output for local Parker user 10000. +argv: ['app', 'list', '--format=json'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/app-list-table.yaml b/testdata/parity-local/app-list-table.yaml new file mode 100644 index 000000000..613b57b89 --- /dev/null +++ b/testdata/parity-local/app-list-table.yaml @@ -0,0 +1,8 @@ +name: local-parker-app-list-table +description: Compare Node and Go table app-list output for local Parker user 10000. +argv: ['app', 'list'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/envvar-list-csv.yaml b/testdata/parity-local/envvar-list-csv.yaml new file mode 100644 index 000000000..77519d221 --- /dev/null +++ b/testdata/parity-local/envvar-list-csv.yaml @@ -0,0 +1,8 @@ +name: local-parker-envvar-list-csv +description: Compare Node and Go CSV environment-variable names for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'envvar', 'list', '--format=csv'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/envvar-list-ids.yaml b/testdata/parity-local/envvar-list-ids.yaml new file mode 100644 index 000000000..350395476 --- /dev/null +++ b/testdata/parity-local/envvar-list-ids.yaml @@ -0,0 +1,8 @@ +name: local-parker-envvar-list-ids +description: Compare Node and Go ID-format environment-variable names for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'envvar', 'list', '--format=ids'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/envvar-list-json.yaml b/testdata/parity-local/envvar-list-json.yaml new file mode 100644 index 000000000..94c175c16 --- /dev/null +++ b/testdata/parity-local/envvar-list-json.yaml @@ -0,0 +1,8 @@ +name: local-parker-envvar-list-json +description: Compare Node and Go JSON environment-variable names for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'envvar', 'list', '--format=json'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/envvar-list-keyvalue.yaml b/testdata/parity-local/envvar-list-keyvalue.yaml new file mode 100644 index 000000000..8507a6785 --- /dev/null +++ b/testdata/parity-local/envvar-list-keyvalue.yaml @@ -0,0 +1,8 @@ +name: local-parker-envvar-list-keyvalue +description: Compare Node and Go key-value environment-variable names for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'envvar', 'list', '--format=keyValue'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/envvar-list-table.yaml b/testdata/parity-local/envvar-list-table.yaml new file mode 100644 index 000000000..99e8f2bc6 --- /dev/null +++ b/testdata/parity-local/envvar-list-table.yaml @@ -0,0 +1,8 @@ +name: local-parker-envvar-list-table +description: Compare Node and Go table environment-variable names for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'envvar', 'list'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/software-get-csv.yaml b/testdata/parity-local/software-get-csv.yaml new file mode 100644 index 000000000..4e841cc3d --- /dev/null +++ b/testdata/parity-local/software-get-csv.yaml @@ -0,0 +1,8 @@ +name: local-parker-software-get-csv +description: Compare Node and Go CSV software metadata for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'software', 'get', '--format=csv'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/software-get-json.yaml b/testdata/parity-local/software-get-json.yaml new file mode 100644 index 000000000..672b4051b --- /dev/null +++ b/testdata/parity-local/software-get-json.yaml @@ -0,0 +1,8 @@ +name: local-parker-software-get-json +description: Compare Node and Go JSON software metadata for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'software', 'get', '--format=json'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/software-get-table.yaml b/testdata/parity-local/software-get-table.yaml new file mode 100644 index 000000000..e1a8dad0b --- /dev/null +++ b/testdata/parity-local/software-get-table.yaml @@ -0,0 +1,8 @@ +name: local-parker-software-get-table +description: Compare Node and Go table software metadata for the discovered local Parker environment. +argv: ['@{{app_name}}.{{env_identifier}}', 'config', 'software', 'get'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity-local/whoami.yaml b/testdata/parity-local/whoami.yaml new file mode 100644 index 000000000..7fa983ade --- /dev/null +++ b/testdata/parity-local/whoami.yaml @@ -0,0 +1,8 @@ +name: local-parker-whoami +description: Compare Node and Go whoami output for local Parker user 10000. +argv: ['whoami'] +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity/app-deploy-completed.yaml b/testdata/parity/app-deploy-completed.yaml new file mode 100644 index 000000000..f51881a6c --- /dev/null +++ b/testdata/parity/app-deploy-completed.yaml @@ -0,0 +1,14 @@ +name: app-deploy-completed +description: | + Happy-path deploy with --skip-confirmation: validate access (deploy + token), file gates, sha256 upload, StartCustomDeploy, success block + with the dashboard deployments URL. Exit 0. +argv: ["app", "deploy", "../../testdata/parity/recordings/app-deploy-validate/clean.tar.gz", "--skip-confirmation", "--app=parityapp", "--env=develop"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + WPVIP_DEPLOY_TOKEN: "deploy-tok" +recording: app-deploy-completed +expect: + exit_code: 0 diff --git a/testdata/parity/app-deploy-missing-token.yaml b/testdata/parity/app-deploy-missing-token.yaml new file mode 100644 index 000000000..720d43427 --- /dev/null +++ b/testdata/parity/app-deploy-missing-token.yaml @@ -0,0 +1,21 @@ +name: app-deploy-missing-token +description: | + `vip app deploy file.tar.gz` without WPVIP_DEPLOY_TOKEN: "Valid custom + deploy key is required." (custom-deploy.ts:33). Exit 1. The archive + fixture is created by the scenario test. +argv: ["app", "deploy", "../../testdata/parity/recordings/app-deploy-validate/clean.tar.gz"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 1 +expected_drift: + signature: "1a07eac4c95795ef66f54fc5ed09f30a87c52c9e78608203f7f1296325ceefc9" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/app-deploy-validate-clean.yaml b/testdata/parity/app-deploy-validate-clean.yaml new file mode 100644 index 000000000..771e1aa8d --- /dev/null +++ b/testdata/parity/app-deploy-validate-clean.yaml @@ -0,0 +1,11 @@ +name: app-deploy-validate-clean +description: | + `vip app deploy validate` against a clean tar.gz (single root + + themes/): green success line. Exit 0. Local-only. +argv: ["app", "deploy", "validate", "../../testdata/parity/recordings/app-deploy-validate/clean.tar.gz"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/app-deploy-validate-missing-themes.yaml b/testdata/parity/app-deploy-validate-missing-themes.yaml new file mode 100644 index 000000000..d0719159b --- /dev/null +++ b/testdata/parity/app-deploy-validate-missing-themes.yaml @@ -0,0 +1,21 @@ +name: app-deploy-validate-missing-themes +description: | + Archive without a themes/ directory under the root folder fails with + "Missing `themes` directory from root folder." + (validations/custom-deploy.ts:15). Exit 1. +argv: ["app", "deploy", "validate", "../../testdata/parity/recordings/app-deploy-validate/no-themes.tar.gz"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 1 +expected_drift: + signature: "d58b6d209238d63ec79caea8d755ce93c8ce6fb7b6cfb0aa905a2bbe84cb018e" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/app-get-baseline.yaml b/testdata/parity/app-get-baseline.yaml new file mode 100644 index 000000000..9c2a328d2 --- /dev/null +++ b/testdata/parity/app-get-baseline.yaml @@ -0,0 +1,14 @@ +name: app-get-baseline +description: | + `vip app example-app` returns a header (id/name/repo) + table of environments. + Node-parity post-processing: 7-char currentCommit, flattened primaryDomain, + hidden deploymentStrategy column, "-" branch for custom-deploy envs. +argv: ["app", "example-app"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-get-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-get-custom-deploy.yaml b/testdata/parity/app-get-custom-deploy.yaml new file mode 100644 index 000000000..12b7e188d --- /dev/null +++ b/testdata/parity/app-get-custom-deploy.yaml @@ -0,0 +1,14 @@ +name: app-get-custom-deploy +description: | + `vip app x` against an env with deploymentStrategy=custom-deploy. The branch + column must render as "-" (Node parity), the original branch value must not + appear, and the deploymentStrategy column must be hidden. +argv: ["app", "x"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-get-custom-deploy +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-get-json.yaml b/testdata/parity/app-get-json.yaml new file mode 100644 index 000000000..edaa0a7a0 --- /dev/null +++ b/testdata/parity/app-get-json.yaml @@ -0,0 +1,14 @@ +name: app-get-json +description: | + `vip app example-app --format=json` against the same payload as + app-get-baseline. Verifies JSON rendering of the HeaderData shape + (header section + envs array with flattened primaryDomain). +argv: ["app", "example-app", "--format=json"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-get-json +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-get-not-found.yaml b/testdata/parity/app-get-not-found.yaml new file mode 100644 index 000000000..77e5c1590 --- /dev/null +++ b/testdata/parity/app-get-not-found.yaml @@ -0,0 +1,13 @@ +name: app-get-not-found +description: | + `vip app ghost` against a mock that returns an empty edges list. Node-parity + behavior: print "App ghost was not found" to stdout and exit 0 (no stderr). +argv: ["app", "ghost"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-get-not-found +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-list-baseline.yaml b/testdata/parity/app-list-baseline.yaml new file mode 100644 index 000000000..60d937196 --- /dev/null +++ b/testdata/parity/app-list-baseline.yaml @@ -0,0 +1,14 @@ +name: app-list-baseline +description: | + `vip app list` against a mock GraphQL server returning two apps. Diff + stdout/stderr/exit-code between Node vip and Go vip-next. First read-only + command parity scenario of M5. +argv: ["app", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-list-csv.yaml b/testdata/parity/app-list-csv.yaml new file mode 100644 index 000000000..c2285b61b --- /dev/null +++ b/testdata/parity/app-list-csv.yaml @@ -0,0 +1,13 @@ +name: app-list-csv +description: | + `vip app list --format=csv` against a mock GraphQL server returning two + apps. Verifies Node-parity CSV column ordering (id,name,repo) and header. +argv: ["app", "list", "--format=csv"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-csv +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-list-empty.yaml b/testdata/parity/app-list-empty.yaml new file mode 100644 index 000000000..4f2474f2e --- /dev/null +++ b/testdata/parity/app-list-empty.yaml @@ -0,0 +1,13 @@ +name: app-list-empty +description: | + `vip app list` against a mock GraphQL server returning zero apps. + Verifies the Node-parity "No apps found" stdout line + exit 0. +argv: ["app", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-empty +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-list-ids.yaml b/testdata/parity/app-list-ids.yaml new file mode 100644 index 000000000..8076d056f --- /dev/null +++ b/testdata/parity/app-list-ids.yaml @@ -0,0 +1,26 @@ +name: app-list-ids +description: | + `vip app list --format=ids` against the app-list-baseline payload. + + Node (src/lib/cli/format.ts `ids()`) accepts the format and prints the id + column space-separated, exit 0. vip-next rejects `ids` on platform commands + and exits 1. + + The repo owner has decided that rejection is intentional and shipping (parity + register 1.3, KEEP), so this scenario records the divergence rather than + demanding it be fixed. expect.exit_code records NODE's behaviour, which is + what a parity fixture asserts. +argv: ["app", "list", "--format=ids"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-baseline +expect: + exit_code: 0 +expected_drift: + signature: "03eacb38e8fbeb4b5d51768597ca0ecb04f339dfe6e7ed93416ea39076d149cf" + reason: >- + parity register 1.3 (KEEP): vip-next deliberately rejects --format ids on + platform commands, where Node prints the id column and exits 0. +normalize: + stdout: [] diff --git a/testdata/parity/app-list-json.yaml b/testdata/parity/app-list-json.yaml new file mode 100644 index 000000000..c1d700942 --- /dev/null +++ b/testdata/parity/app-list-json.yaml @@ -0,0 +1,13 @@ +name: app-list-json +description: | + `vip app list --format=json` against a mock GraphQL server returning two + apps. Verifies Node-parity JSON column ordering (id, name, repo). +argv: ["app", "list", "--format=json"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-json +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/app-list-unknown-format.yaml b/testdata/parity/app-list-unknown-format.yaml new file mode 100644 index 000000000..f54e9378a --- /dev/null +++ b/testdata/parity/app-list-unknown-format.yaml @@ -0,0 +1,29 @@ +name: app-list-unknown-format +description: | + `vip app list --format=bogus` against the app-list-baseline payload. + + Node does not validate the format string on `app list`: src/lib/cli/format.ts + `formatData()` has `case 'table': default:`, so an unrecognised value falls + through to the table renderer and exits 0. vip-next rejects it and exits 1. + + Arguably vip-next is the better behaviour, but it is a breaking change against + the shipping CLI: a script passing a typo'd or newly-added format keeps + working on Node and starts failing on vip-next. The repo owner has decided to + keep the rejection (parity register 1.2, KEEP); the call belongs in the + cutover breaking-changes doc. + + expect.exit_code records NODE's behaviour. +argv: ["app", "list", "--format=bogus"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: app-list-baseline +expect: + exit_code: 0 +expected_drift: + signature: "e7be0803b0617d8d3744ba91062f5e2dae58d3d99ebbab120250c1990e306ccf" + reason: >- + parity register 1.2 (KEEP): vip-next rejects an unknown --format value with + exit 1, where Node silently falls through to the table renderer and exits 0. +normalize: + stdout: [] diff --git a/testdata/parity/backup-db-already-in-progress.yaml b/testdata/parity/backup-db-already-in-progress.yaml new file mode 100644 index 000000000..859f918b9 --- /dev/null +++ b/testdata/parity/backup-db-already-in-progress.yaml @@ -0,0 +1,15 @@ +name: backup-db-already-in-progress +description: | + First status fetch reports inProgressLock=true: the trigger mutation + MUST NOT fire and the command prints "Database backup already in + progress..." (backup-db.ts:151), then attaches until the lock clears. + Exit 0. +argv: ["@parityapp.develop", "backup", "db"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_BACKUP_DB_INTERVAL_MS: "5" +recording: backup-db-already-in-progress +expect: + exit_code: 0 diff --git a/testdata/parity/backup-db-completed.yaml b/testdata/parity/backup-db-completed.yaml new file mode 100644 index 000000000..05ac1adf3 --- /dev/null +++ b/testdata/parity/backup-db-completed.yaml @@ -0,0 +1,14 @@ +name: backup-db-completed +description: | + Full backup flow: no job -> trigger -> lock held -> lock clears with + status success. "Generating a new database backup..." + + "New database backup created" (backup-db.ts:154,223). Exit 0. +argv: ["@parityapp.develop", "backup", "db"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_BACKUP_DB_INTERVAL_MS: "5" +recording: backup-db-completed +expect: + exit_code: 0 diff --git a/testdata/parity/backup-db-help.yaml b/testdata/parity/backup-db-help.yaml new file mode 100644 index 000000000..55d78a04f --- /dev/null +++ b/testdata/parity/backup-db-help.yaml @@ -0,0 +1,10 @@ +name: backup-db-help +description: | + `vip backup db --help` renders usage. No network. Exit 0. +argv: ["backup", "db", "--help"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/cache-purge-url-empty.yaml b/testdata/parity/cache-purge-url-empty.yaml new file mode 100644 index 000000000..a6f6a5699 --- /dev/null +++ b/testdata/parity/cache-purge-url-empty.yaml @@ -0,0 +1,26 @@ +name: cache-purge-url-empty +description: | + No positional URLs and no --from-file. The handler prints + "Please supply at least one URL." and exits 1 BEFORE issuing the + mutation. purge.json is intentionally omitted from the recording so + any unexpected mutation call surfaces loudly via the hit-counter + assertion in the parity test. +argv: + - "@parityapp.develop" + - "cache" + - "purge-url" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: cache-purge-url-empty +expect: + exit_code: 1 +expected_drift: + signature: "8875113c6fa956e8622ccce15a6f4041352607f428e1158f5606dfdf4ce569db" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/cache-purge-url-from-file.yaml b/testdata/parity/cache-purge-url-from-file.yaml new file mode 100644 index 000000000..8f42e71ec --- /dev/null +++ b/testdata/parity/cache-purge-url-from-file.yaml @@ -0,0 +1,21 @@ +name: cache-purge-url-from-file +description: | + `--from-file=<path>` reads URLs from a file (one per line, trimmed, + empty lines dropped) and IGNORES any positional URLs. The fixture + includes a positional URL that must NOT appear in the response or + wire body — the runner asserts this via the mutation-mux body checks + on the per-scenario test setup (test asserts file URLs are returned). +argv: + - "@parityapp.develop" + - "cache" + - "purge-url" + - "https://example.com/IGNORED" + - "--from-file=../../testdata/parity/recordings/cache-purge-url-from-file/urls.txt" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: cache-purge-url-from-file +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/cache-purge-url-multi.yaml b/testdata/parity/cache-purge-url-multi.yaml new file mode 100644 index 000000000..f6de56e82 --- /dev/null +++ b/testdata/parity/cache-purge-url-multi.yaml @@ -0,0 +1,19 @@ +name: cache-purge-url-multi +description: | + Three positional URLs. The mutation echoes all three back; stdout has + one `- Purged URL: <u>` line per URL. +argv: + - "@parityapp.develop" + - "cache" + - "purge-url" + - "https://example-app.go-vip.co/page-a/" + - "https://example-app.go-vip.co/page-b/" + - "https://example-app.go-vip.co/page-c/" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: cache-purge-url-multi +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/cache-purge-url-single.yaml b/testdata/parity/cache-purge-url-single.yaml new file mode 100644 index 000000000..683b9dc09 --- /dev/null +++ b/testdata/parity/cache-purge-url-single.yaml @@ -0,0 +1,18 @@ +name: cache-purge-url-single +description: | + `vip @parityapp.develop cache purge-url <URL>` with a single positional + URL. The mutation returns the URL as-is (server canonicalization may + differ in production); stdout has `- Purged URL: <URL>`. +argv: + - "@parityapp.develop" + - "cache" + - "purge-url" + - "https://example-app.go-vip.co/sample-page/" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: cache-purge-url-single +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/defensive-mode-enable-with-rechallenge.yaml b/testdata/parity/defensive-mode-enable-with-rechallenge.yaml new file mode 100644 index 000000000..361eb68a3 --- /dev/null +++ b/testdata/parity/defensive-mode-enable-with-rechallenge.yaml @@ -0,0 +1,20 @@ +name: defensive-mode-enable-with-rechallenge +description: | + Server returns elevated-permission-required on the first mutation; + Parker mock completes step-up; retry succeeds with elevated header. + This is the M3 acceptance scenario. +argv: ["defensive-mode", "enable", "--app=parityapp", "--env=develop", "--skip-confirmation", "--non-interactive"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + # --non-interactive now refuses step-up before CreateSession: a challenge + # nobody can approve is unsatisfiable, and polling it to session expiry hung + # CI. This scenario still needs the challenge to COMPLETE, so it takes the + # documented opt-in that prints the verification URL and waits — the same + # escape hatch the new error message tells users about. + VIP_RECHALLENGE_WAIT: "1" +recording: defensive-mode-enable-rechallenge +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-delete-baseline.yaml b/testdata/parity/envvar-delete-baseline.yaml new file mode 100644 index 000000000..99a3d92af --- /dev/null +++ b/testdata/parity/envvar-delete-baseline.yaml @@ -0,0 +1,19 @@ +name: envvar-delete-baseline +description: | + `vip @parityapp.develop config envvar delete MY_VAR --skip-confirmation`. + Non-prod + skip-confirmation = no prompt → mutation succeeds → exit 0. +argv: + - "@parityapp.develop" + - "config" + - "envvar" + - "delete" + - "MY_VAR" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-delete-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-delete-prod-cancel.yaml b/testdata/parity/envvar-delete-prod-cancel.yaml new file mode 100644 index 000000000..21d0775d3 --- /dev/null +++ b/testdata/parity/envvar-delete-prod-cancel.yaml @@ -0,0 +1,41 @@ +name: envvar-delete-prod-cancel +description: | + Production env, no --skip-confirmation, non-interactive context → + prod-gate Confirm returns ErrNonInteractive → handler prints + "Command cancelled" → exit 0. delete.json is INTENTIONALLY omitted + so the test fails if the mutation fires anyway. +argv: + - "@parityapp.production" + - "config" + - "envvar" + - "delete" + - "MY_VAR" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + VIP_NON_INTERACTIVE: "1" +recording: envvar-delete-prod-cancel +expect: + exit_code: 0 +expected_drift: + signature: "18421938ea6a2bbceadf9dcb48cd1d6adbcc7bc793582ac6621567887474390b" + reason: >- + parity register 1.24 (KEEP): in a non-TTY vip-next explicitly reports that + it cannot prompt and cancels without mutating; Node emits enquirer's raw + ANSI prompt before its unresolved prompt exits without mutating. +normalize: + # Node renders this prompt through enquirer, which writes it to stdout with + # colour and cursor control. Those sequences differ between macOS and a + # headless Linux runner, so without stripping them the drift signature below + # is platform-specific and can only ever match on one of the two. + # + # enquirer also picks its pointer glyph by platform: U+203A on macOS, + # U+2023 on Linux. That is the difference that actually broke this scenario + # in CI; the ANSI rule above alone was not enough. + # + # The prompt's text survives, so the divergence this scenario exists to + # record - Node prompting where vip-next refuses to - is still compared. + stdout: + - '\x1b\[[0-9;?]*[a-zA-Z] -> ' + - '[\x{2023}\x{203A}] -> <POINTER>' + stderr: [] diff --git a/testdata/parity/envvar-delete-typed-mismatch.yaml b/testdata/parity/envvar-delete-typed-mismatch.yaml new file mode 100644 index 000000000..a4ccbe21b --- /dev/null +++ b/testdata/parity/envvar-delete-typed-mismatch.yaml @@ -0,0 +1,44 @@ +name: envvar-delete-typed-mismatch +description: | + `vip @parityapp.develop config envvar delete MY_VAR` WITHOUT + --skip-confirmation, with VIP_NON_INTERACTIVE=1 forcing appctx.Input to + return ErrNonInteractive in the typed-name confirm gate. Handler treats + it as decline -> yellow "Command cancelled by user." to stdout -> exit 0. + The DeleteEnvironmentVariable mutation MUST NOT fire (wire-level + assertion via envvarCancelScenarios in envvar_mutation_scenario_test.go). +argv: + - "@parityapp.develop" + - "config" + - "envvar" + - "delete" + - "MY_VAR" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +recording: envvar-delete-typed-mismatch +expect: + exit_code: 0 +expected_drift: + signature: "744bc383afa791a55bbf6e6f06829fe988367d0659e3838a5df07fcbe9312f03" + reason: >- + parity register 1.24 (KEEP): in a non-TTY vip-next explicitly cancels the + typed-name gate without mutating; Node emits enquirer's raw ANSI prompt + before its unresolved prompt exits without mutating. +normalize: + # Node renders this prompt through enquirer, which writes it to stdout with + # colour and cursor control. Those sequences differ between macOS and a + # headless Linux runner, so without stripping them the drift signature below + # is platform-specific and can only ever match on one of the two. + # + # enquirer also picks its pointer glyph by platform: U+203A on macOS, + # U+2023 on Linux. That is the difference that actually broke this scenario + # in CI; the ANSI rule above alone was not enough. + # + # The prompt's text survives, so the divergence this scenario exists to + # record - Node prompting where vip-next refuses to - is still compared. + stdout: + - '\x1b\[[0-9;?]*[a-zA-Z] -> ' + - '[\x{2023}\x{203A}] -> <POINTER>' + stderr: [] diff --git a/testdata/parity/envvar-get-baseline.yaml b/testdata/parity/envvar-get-baseline.yaml new file mode 100644 index 000000000..9f3cce88f --- /dev/null +++ b/testdata/parity/envvar-get-baseline.yaml @@ -0,0 +1,14 @@ +name: envvar-get-baseline +description: | + `vip @parityapp.develop config envvar get FOO`. Verifies the resolved + value prints to stdout with no other formatting (Node parity: + `console.log( envvar.value )` in src/bin/vip-config-envvar-get.js). +argv: ["@parityapp.develop", "config", "envvar", "get", "FOO"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-get-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-get-lowercase-input.yaml b/testdata/parity/envvar-get-lowercase-input.yaml new file mode 100644 index 000000000..ae3d84a23 --- /dev/null +++ b/testdata/parity/envvar-get-lowercase-input.yaml @@ -0,0 +1,14 @@ +name: envvar-get-lowercase-input +description: | + `vip @parityapp.develop config envvar get foo` (lowercase). Verifies + Node-parity uppercasing: the argument is trimmed and uppercased before + lookup, so the lowercase form resolves to FOO and prints its value. +argv: ["@parityapp.develop", "config", "envvar", "get", "foo"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-get-lowercase-input +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-get-named-help.yaml b/testdata/parity/envvar-get-named-help.yaml new file mode 100644 index 000000000..7630dac07 --- /dev/null +++ b/testdata/parity/envvar-get-named-help.yaml @@ -0,0 +1,24 @@ +name: envvar-get-named-help +description: | + `vip @parityapp.develop config envvar get help` — end-to-end reproduction of + cutover item 2.13 through the shipped binary. + + Both CLIs decide "run the login flow or not" with a FLAT scan of the whole + argv (src/bin/vip.js:192, `argv.some(arg => params.includes(arg))`), so the + positional "help" here takes the bypass branch on Node too. That is harmless + in Node because bypassing only skips the PROMPT: runCmd() still calls the API, + with src/lib/api/http.ts re-reading the token per request. vip-next used to + treat the same branch as "no API setup at all" and exited 1 with + "appctx: GraphQL client not configured" before a single request was made. + + Exit 0 plus the variable's value on stdout is the assertion; a regression + makes this scenario exit 1 without touching the mock server. +argv: ["@parityapp.develop", "config", "envvar", "get", "help"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-get-named-help +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-get-not-found.yaml b/testdata/parity/envvar-get-not-found.yaml new file mode 100644 index 000000000..13345540b --- /dev/null +++ b/testdata/parity/envvar-get-not-found.yaml @@ -0,0 +1,17 @@ +name: envvar-get-not-found +description: | + `vip @parityapp.develop config envvar get MISSING` when MISSING is not + in the env-var set. Verifies Node-parity yellow stdout message and + exit 0 (Node calls process.exit() with no arg → exit 0). The not-found + name is wrapped in JSON.stringify on the Node side, which renders the + exact byte sequence `"MISSING"` (matching Go's %q). +argv: ["@parityapp.develop", "config", "envvar", "get", "MISSING"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: envvar-get-not-found +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-getall-baseline.yaml b/testdata/parity/envvar-getall-baseline.yaml new file mode 100644 index 000000000..781bf1049 --- /dev/null +++ b/testdata/parity/envvar-getall-baseline.yaml @@ -0,0 +1,14 @@ +name: envvar-getall-baseline +description: | + `vip @parityapp.develop config envvar get-all` returns the default-format + table of (name, value) for three env vars. Verifies column ordering and + table rendering. +argv: ["@parityapp.develop", "config", "envvar", "get-all"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-getall-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-getall-empty.yaml b/testdata/parity/envvar-getall-empty.yaml new file mode 100644 index 000000000..ed95fd85c --- /dev/null +++ b/testdata/parity/envvar-getall-empty.yaml @@ -0,0 +1,15 @@ +name: envvar-getall-empty +description: | + `vip @parityapp.develop config envvar get-all` with zero env vars + present. Verifies the Node-parity yellow "There are no environment + variables" stdout line + exit 0. +argv: ["@parityapp.develop", "config", "envvar", "get-all"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: envvar-getall-empty +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-getall-keyvalue.yaml b/testdata/parity/envvar-getall-keyvalue.yaml new file mode 100644 index 000000000..48f962961 --- /dev/null +++ b/testdata/parity/envvar-getall-keyvalue.yaml @@ -0,0 +1,22 @@ +name: envvar-getall-keyvalue +description: | + `vip @parityapp.develop config envvar get-all --format=keyValue`. + Verifies Node parity: the first column flips from "name" to "key" + but the second column stays "value" (src/bin/vip-config-envvar-get-all.js). +argv: ["@parityapp.develop", "config", "envvar", "get-all", "--format=keyValue"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-getall-keyvalue +expect: + exit_code: 0 +expected_drift: + signature: "72a4f28aba23ca49eb6593e7401fbd12e5776936f005409f49a54be879505f06" + reason: >- + parity register section 3 (KEEP): vip-next renders --format=keyValue as bare + "key=value" lines, where Node (src/lib/cli/format.ts keyValue()) brackets the + block in "===" banners and prefixes each line with "+ ". For `envvar list`, + which supplies no value, Node additionally prints the literal string + "undefined" as the value. +normalize: + stdout: [] diff --git a/testdata/parity/envvar-list-baseline.yaml b/testdata/parity/envvar-list-baseline.yaml new file mode 100644 index 000000000..7d7091652 --- /dev/null +++ b/testdata/parity/envvar-list-baseline.yaml @@ -0,0 +1,15 @@ +name: envvar-list-baseline +description: | + `vip @parityapp.develop config envvar list` against a mock GraphQL server + returning three env-var names. Diff stdout/stderr/exit-code between Node + vip and Go vip-next. Verifies Node-parity table output ordering on the + default format. +argv: ["@parityapp.develop", "config", "envvar", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-list-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-list-empty.yaml b/testdata/parity/envvar-list-empty.yaml new file mode 100644 index 000000000..2cc939064 --- /dev/null +++ b/testdata/parity/envvar-list-empty.yaml @@ -0,0 +1,15 @@ +name: envvar-list-empty +description: | + `vip @parityapp.develop config envvar list` against a server returning + zero env vars. Verifies the Node-parity "There are no environment + variables" yellow stdout line + exit 0 (NO_COLOR=1 strips ANSI). +argv: ["@parityapp.develop", "config", "envvar", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: envvar-list-empty +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-list-ids.yaml b/testdata/parity/envvar-list-ids.yaml new file mode 100644 index 000000000..d69287b8a --- /dev/null +++ b/testdata/parity/envvar-list-ids.yaml @@ -0,0 +1,14 @@ +name: envvar-list-ids +description: | + `vip @parityapp.develop config envvar list --format=ids`. Verifies + Node parity: the column key flips from "name" to "id" (used by the + Node `ids` renderer for space-separated output). +argv: ["@parityapp.develop", "config", "envvar", "list", "--format=ids"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-list-ids +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-list-json.yaml b/testdata/parity/envvar-list-json.yaml new file mode 100644 index 000000000..a334a0fbd --- /dev/null +++ b/testdata/parity/envvar-list-json.yaml @@ -0,0 +1,14 @@ +name: envvar-list-json +description: | + `vip @parityapp.develop config envvar list --format=json` returns the + same three names rendered as a JSON array. Verifies Node-parity JSON + encoding (insertion-ordered keys, top-level array, no header wrapper). +argv: ["@parityapp.develop", "config", "envvar", "list", "--format=json"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-list-json +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-list-keyvalue.yaml b/testdata/parity/envvar-list-keyvalue.yaml new file mode 100644 index 000000000..dc706b8de --- /dev/null +++ b/testdata/parity/envvar-list-keyvalue.yaml @@ -0,0 +1,22 @@ +name: envvar-list-keyvalue +description: | + `vip @parityapp.develop config envvar list --format=keyValue`. Verifies + Node parity: the column key flips from "name" to "key" when keyValue + format is requested (src/bin/vip-config-envvar-list.js). +argv: ["@parityapp.develop", "config", "envvar", "list", "--format=keyValue"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-list-keyvalue +expect: + exit_code: 0 +expected_drift: + signature: "8c3b9ffc1918cf0b8c69e1827e351635901b9e5ca3bb1ad219148d52f554c811" + reason: >- + parity register section 3 (KEEP): vip-next renders --format=keyValue as bare + "key=value" lines, where Node (src/lib/cli/format.ts keyValue()) brackets the + block in "===" banners and prefixes each line with "+ ". For `envvar list`, + which supplies no value, Node additionally prints the literal string + "undefined" as the value. +normalize: + stdout: [] diff --git a/testdata/parity/envvar-set-baseline.yaml b/testdata/parity/envvar-set-baseline.yaml new file mode 100644 index 000000000..c4df5241f --- /dev/null +++ b/testdata/parity/envvar-set-baseline.yaml @@ -0,0 +1,21 @@ +name: envvar-set-baseline +description: | + `vip @parityapp.develop config envvar set MY_VAR --from-file=... --skip-confirmation`. + Non-prod + skip-confirmation = no prompt. value.txt content is trimmed + (Node parity: data.trim()) so the wire-level value is "hello". +argv: + - "@parityapp.develop" + - "config" + - "envvar" + - "set" + - "MY_VAR" + - "--from-file=../../testdata/parity/recordings/envvar-set-baseline/value.txt" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-set-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-set-invalid-name.yaml b/testdata/parity/envvar-set-invalid-name.yaml new file mode 100644 index 000000000..7f927019e --- /dev/null +++ b/testdata/parity/envvar-set-invalid-name.yaml @@ -0,0 +1,20 @@ +name: envvar-set-invalid-name +description: | + `bad-name-with-dash` fails ValidateName (the dash strips out and the + result no longer round-trips). Exit 1. The mutation must not fire. +argv: + - "@parityapp.develop" + - "config" + - "envvar" + - "set" + - "bad-name-with-dash" + - "--from-file=../../testdata/parity/recordings/envvar-set-invalid-name/value.txt" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-set-invalid-name +expect: + exit_code: 1 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-set-newrelic-blocked.yaml b/testdata/parity/envvar-set-newrelic-blocked.yaml new file mode 100644 index 000000000..352e872ef --- /dev/null +++ b/testdata/parity/envvar-set-newrelic-blocked.yaml @@ -0,0 +1,21 @@ +name: envvar-set-newrelic-blocked +description: | + Lowercase `new_relic_license_key` is uppercased to NEW_RELIC_LICENSE_KEY + BEFORE the block check (Node parity). Must exit 1. add.json is omitted + so a mutation attempt would 500 — the block must fire first. +argv: + - "@parityapp.develop" + - "config" + - "envvar" + - "set" + - "new_relic_license_key" + - "--from-file=../../testdata/parity/recordings/envvar-set-newrelic-blocked/value.txt" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-set-newrelic-blocked +expect: + exit_code: 1 +normalize: + stdout: [] diff --git a/testdata/parity/envvar-set-prod-cancel.yaml b/testdata/parity/envvar-set-prod-cancel.yaml new file mode 100644 index 000000000..97a5bbd22 --- /dev/null +++ b/testdata/parity/envvar-set-prod-cancel.yaml @@ -0,0 +1,42 @@ +name: envvar-set-prod-cancel +description: | + Production env, no --skip-confirmation, non-interactive context → + prod-gate Confirm returns ErrNonInteractive → handler prints + "Command cancelled" → exit 0. The add.json fixture is INTENTIONALLY + omitted so the test fails loudly if the mutation is fired anyway. +argv: + - "@parityapp.production" + - "config" + - "envvar" + - "set" + - "MY_VAR" + - "--from-file=../../testdata/parity/recordings/envvar-set-prod-cancel/value.txt" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + VIP_NON_INTERACTIVE: "1" +recording: envvar-set-prod-cancel +expect: + exit_code: 0 +expected_drift: + signature: "1e44b3a584444dd18ab5e960f5df46a757ee337a0ac16526383a2a075b26381b" + reason: >- + parity register 1.24 (KEEP): in a non-TTY vip-next explicitly reports that + it cannot prompt and cancels without mutating; Node emits enquirer's raw + ANSI prompt before its unresolved prompt exits without mutating. +normalize: + # Node renders this prompt through enquirer, which writes it to stdout with + # colour and cursor control. Those sequences differ between macOS and a + # headless Linux runner, so without stripping them the drift signature below + # is platform-specific and can only ever match on one of the two. + # + # enquirer also picks its pointer glyph by platform: U+203A on macOS, + # U+2023 on Linux. That is the difference that actually broke this scenario + # in CI; the ANSI rule above alone was not enough. + # + # The prompt's text survives, so the divergence this scenario exists to + # record - Node prompting where vip-next refuses to - is still compared. + stdout: + - '\x1b\[[0-9;?]*[a-zA-Z] -> ' + - '[\x{2023}\x{203A}] -> <POINTER>' + stderr: [] diff --git a/testdata/parity/envvar-set-prod-confirm-skipped.yaml b/testdata/parity/envvar-set-prod-confirm-skipped.yaml new file mode 100644 index 000000000..a97ef2d6f --- /dev/null +++ b/testdata/parity/envvar-set-prod-confirm-skipped.yaml @@ -0,0 +1,20 @@ +name: envvar-set-prod-confirm-skipped +description: | + Production env but --skip-confirmation present → no prompt → success. + Verifies the prod-gate respects the skip flag. +argv: + - "@parityapp.production" + - "config" + - "envvar" + - "set" + - "MY_VAR" + - "--from-file=../../testdata/parity/recordings/envvar-set-prod-confirm-skipped/value.txt" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: envvar-set-prod-confirm-skipped +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/export-sql-completed.yaml b/testdata/parity/export-sql-completed.yaml new file mode 100644 index 000000000..f0cf7d4c9 --- /dev/null +++ b/testdata/parity/export-sql-completed.yaml @@ -0,0 +1,14 @@ +name: export-sql-completed +description: | + Full export flow with --skip-download (parity scenarios avoid writing + into the repo): create export job -> poll preflight + upload_backup -> + download link. Exit 0. +argv: ["@parityapp.develop", "export", "sql", "--skip-download"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_EXPORT_SQL_INTERVAL_MS: "5" +recording: export-sql-completed +expect: + exit_code: 0 diff --git a/testdata/parity/export-sql-config-conflict.yaml b/testdata/parity/export-sql-config-conflict.yaml new file mode 100644 index 000000000..f6dd0cbeb --- /dev/null +++ b/testdata/parity/export-sql-config-conflict.yaml @@ -0,0 +1,21 @@ +name: export-sql-config-conflict +description: | + --config-file combined with --table fails fast with the exclusivity + message (live-backup-copy.ts:30). Exit 1, no network calls. +argv: ["@parityapp.develop", "export", "sql", "--config-file=cfg.json", "--table=wp_posts"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: export-sql-config-conflict +expect: + exit_code: 1 +expected_drift: + signature: "a3c50a651ee932b630704796645ebb44a4b4066a2ecb4ee389177285694a85fa" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/export-sql-help.yaml b/testdata/parity/export-sql-help.yaml new file mode 100644 index 000000000..eaca7cfbe --- /dev/null +++ b/testdata/parity/export-sql-help.yaml @@ -0,0 +1,11 @@ +name: export-sql-help +description: | + `vip export sql --help` lists output/table/site-id/wpcli-command/ + config-file/generate-backup/skip-download. Exit 0. +argv: ["export", "sql", "--help"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/import-media-abort-noninteractive.yaml b/testdata/parity/import-media-abort-noninteractive.yaml new file mode 100644 index 000000000..09d8d3f2c --- /dev/null +++ b/testdata/parity/import-media-abort-noninteractive.yaml @@ -0,0 +1,19 @@ +name: import-media-abort-noninteractive +description: | + `vip import media abort` without --skip-confirmation under + VIP_NON_INTERACTIVE=1: the requireConfirm gate declines, the + AbortMediaImport mutation MUST NOT fire, exit 0 (the M6 + WithRequireConfirm cancel path). +argv: + - "@parityapp.develop" + - "import" + - "media" + - "abort" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +recording: import-media-abort-noninteractive +expect: + exit_code: 0 diff --git a/testdata/parity/import-media-help.yaml b/testdata/parity/import-media-help.yaml new file mode 100644 index 000000000..7ea944fc8 --- /dev/null +++ b/testdata/parity/import-media-help.yaml @@ -0,0 +1,12 @@ +name: import-media-help +description: | + `vip import media --help` renders the flag set + (exportFileErrorsToJson, saveErrorLog, overwriteExistingFiles, + importIntermediateImages) plus the status/abort subcommands. Exit 0. +argv: ["import", "media", "--help"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/import-media-invalid-archive.yaml b/testdata/parity/import-media-invalid-archive.yaml new file mode 100644 index 000000000..decd50f2c --- /dev/null +++ b/testdata/parity/import-media-invalid-archive.yaml @@ -0,0 +1,18 @@ +name: import-media-invalid-archive +description: | + A non-archive local path prints the red "Invalid local archive + provided" block and exits 0 (vip-import-media.js:169-176 — console.log + + return). StartMediaImport MUST NOT fire. +argv: + - "@parityapp.develop" + - "import" + - "media" + - "../../testdata/parity/recordings/import-media-invalid-archive/file.sql" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-media-invalid-archive +expect: + exit_code: 0 diff --git a/testdata/parity/import-media-status-completed.yaml b/testdata/parity/import-media-status-completed.yaml new file mode 100644 index 000000000..2854d9dd2 --- /dev/null +++ b/testdata/parity/import-media-status-completed.yaml @@ -0,0 +1,19 @@ +name: import-media-status-completed +description: | + `vip import media status` against a COMPLETED import renders the final + Status/App block and exits 0. saveErrorLog=false skips the download + prompt path. +argv: + - "@parityapp.develop" + - "import" + - "media" + - "status" + - "--saveErrorLog=false" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_IMPORT_MEDIA_INTERVAL_MS: "5" +recording: import-media-status-completed +expect: + exit_code: 0 diff --git a/testdata/parity/import-media-status-failed.yaml b/testdata/parity/import-media-status-failed.yaml new file mode 100644 index 000000000..9fa3edbe5 --- /dev/null +++ b/testdata/parity/import-media-status-failed.yaml @@ -0,0 +1,18 @@ +name: import-media-status-failed +description: | + `vip import media status` against a FAILED import prints "Import + failed at status: RUNNING" + global errors and exits 1 + (media-import/status.ts:384). +argv: + - "@parityapp.develop" + - "import" + - "media" + - "status" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_IMPORT_MEDIA_INTERVAL_MS: "5" +recording: import-media-status-failed +expect: + exit_code: 1 diff --git a/testdata/parity/import-media-url-completed.yaml b/testdata/parity/import-media-url-completed.yaml new file mode 100644 index 000000000..79382d13d --- /dev/null +++ b/testdata/parity/import-media-url-completed.yaml @@ -0,0 +1,18 @@ +name: import-media-url-completed +description: | + URL import happy path: StartMediaImport fires once with apiVersion v2, + polling reaches COMPLETED, exit 0. +argv: + - "@parityapp.develop" + - "import" + - "media" + - "https://example.com/uploads.zip" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_IMPORT_MEDIA_INTERVAL_MS: "5" +recording: import-media-url-completed +expect: + exit_code: 0 diff --git a/testdata/parity/import-sql-bad-extension.yaml b/testdata/parity/import-sql-bad-extension.yaml new file mode 100644 index 000000000..f3ca7f010 --- /dev/null +++ b/testdata/parity/import-sql-bad-extension.yaml @@ -0,0 +1,26 @@ +name: import-sql-bad-extension +description: | + `vip @parityapp.develop import sql file.txt` fails the extension gate: + "Invalid file extension. Please provide a .sql or .gz file." + (Node sql.ts:101). The StartImport mutation MUST NOT fire. Exit 1. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "../../testdata/parity/recordings/import-sql-bad-extension/file.txt" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-sql-bad-extension +expect: + exit_code: 1 +expected_drift: + signature: "bd2d07a09b4fc40bcac829385394ce4f76dcc392c628282abe998c159145b122" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/import-sql-help.yaml b/testdata/parity/import-sql-help.yaml new file mode 100644 index 000000000..747b7e485 --- /dev/null +++ b/testdata/parity/import-sql-help.yaml @@ -0,0 +1,12 @@ +name: import-sql-help +description: | + `vip import sql --help` renders the full flag set (skip-validate, + search-replace, in-place, output, skip-maintenance-mode, md5, header, + skip-backup) plus the status subcommand. No network. Exit 0. +argv: ["import", "sql", "--help"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/import-sql-in-progress.yaml b/testdata/parity/import-sql-in-progress.yaml new file mode 100644 index 000000000..cce352727 --- /dev/null +++ b/testdata/parity/import-sql-in-progress.yaml @@ -0,0 +1,26 @@ +name: import-sql-in-progress +description: | + Env info reports importInProgress=true: the gate aborts with + "There is already an import in progress." + the status-command hint + (vip-import-sql.js:258). Exit 1. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "../../testdata/parity/recordings/import-sql-noninteractive-abort/clean.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-sql-in-progress +expect: + exit_code: 1 +expected_drift: + signature: "33749d4e42c34bcf3256bab862e40dcae5f3e9bee8a2c0a6dce442e8de421fa7" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/import-sql-invalid-md5.yaml b/testdata/parity/import-sql-invalid-md5.yaml new file mode 100644 index 000000000..bacfe4eee --- /dev/null +++ b/testdata/parity/import-sql-invalid-md5.yaml @@ -0,0 +1,27 @@ +name: import-sql-invalid-md5 +description: | + Remote-URL import with a malformed --md5 fails the md5 gate: + "The provided MD5 hash is invalid. It should be a 32-character + hexadecimal string." (vip-import-sql.js:161). Exit 1. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "https://example.com/file.sql" + - "--md5=not-a-hash" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-sql-invalid-md5 +expect: + exit_code: 1 +expected_drift: + signature: "e959faf0d34f029f4dd087159f0cf06683fdf6041310c2ed2d7d40e85a75f774" + reason: >- + parity register 1.10 (KEEP): vip-next emits a clean one-line error on + stderr; Node adds a second space after Error and a runtime Debug line on + stdout. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/import-sql-noninteractive-abort.yaml b/testdata/parity/import-sql-noninteractive-abort.yaml new file mode 100644 index 000000000..40f6f4445 --- /dev/null +++ b/testdata/parity/import-sql-noninteractive-abort.yaml @@ -0,0 +1,20 @@ +name: import-sql-noninteractive-abort +description: | + Clean dump passes gates + validation + playbook, then the + type-the-domain confirm cannot prompt under VIP_NON_INTERACTIVE=1 and + aborts: "The input did not match the expected environment label. + Import aborted." (vip-import-sql.js:349). StartImport MUST NOT fire. + Exit 1. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "../../testdata/parity/recordings/import-sql-noninteractive-abort/clean.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +recording: import-sql-noninteractive-abort +expect: + exit_code: 1 diff --git a/testdata/parity/import-sql-status-completed.yaml b/testdata/parity/import-sql-status-completed.yaml new file mode 100644 index 000000000..b6d55f66b --- /dev/null +++ b/testdata/parity/import-sql-status-completed.yaml @@ -0,0 +1,17 @@ +name: import-sql-status-completed +description: | + `vip import sql status` against a completed sql_import job renders the + final Status/Site block with Success and exits 0. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "status" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_IMPORT_SQL_INTERVAL_MS: "5" +recording: import-sql-status-completed +expect: + exit_code: 0 diff --git a/testdata/parity/import-sql-status-no-job.yaml b/testdata/parity/import-sql-status-no-job.yaml new file mode 100644 index 000000000..907ea74f6 --- /dev/null +++ b/testdata/parity/import-sql-status-no-job.yaml @@ -0,0 +1,17 @@ +name: import-sql-status-no-job +description: | + `vip import sql status` with no import job returns fast with + "No import job found" (status.ts:329) and exits 0. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "status" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_IMPORT_SQL_INTERVAL_MS: "5" +recording: import-sql-status-no-job +expect: + exit_code: 0 diff --git a/testdata/parity/import-sql-validation-failure.yaml b/testdata/parity/import-sql-validation-failure.yaml new file mode 100644 index 000000000..294cb5c4f --- /dev/null +++ b/testdata/parity/import-sql-validation-failure.yaml @@ -0,0 +1,18 @@ +name: import-sql-validation-failure +description: | + Local dump containing DROP DATABASE + ENGINE=MyISAM fails the static + SQL validation in import mode: the joined error report ends with the + "SQL validation failed due to N error(s)" footer and the + --skip-validate advice (vip-import-sql.js:436). Exit 1. +argv: + - "@parityapp.develop" + - "import" + - "sql" + - "../../testdata/parity/recordings/import-sql-validation-failure/dirty.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-sql-validation-failure +expect: + exit_code: 1 diff --git a/testdata/parity/import-validate-files-clean.yaml b/testdata/parity/import-validate-files-clean.yaml new file mode 100644 index 000000000..22efb446f --- /dev/null +++ b/testdata/parity/import-validate-files-clean.yaml @@ -0,0 +1,18 @@ +name: import-validate-files-clean +description: | + Clean uploads/2020/06 fixture: file checks pass (jpg/png allowed, + small files, short sane names), summary renders PASS lines, exit 0. + The folder-structure block may be RECOMMENDED because the fixture's + absolute path doesn't start at `uploads` — Node behaves identically + for absolute inputs. +argv: + - "import" + - "validate-files" + - "../../testdata/parity/recordings/import-validate-files-clean/uploads" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-validate-files-clean +expect: + exit_code: 0 diff --git a/testdata/parity/import-validate-files-not-dir.yaml b/testdata/parity/import-validate-files-not-dir.yaml new file mode 100644 index 000000000..492963bb3 --- /dev/null +++ b/testdata/parity/import-validate-files-not-dir.yaml @@ -0,0 +1,16 @@ +name: import-validate-files-not-dir +description: | + `vip import validate-files` against a file (not a directory) prints + the not-a-directory error to stderr and exits 0 + (vip-import-validate-files.js:33-39). +argv: + - "import" + - "validate-files" + - "../../testdata/parity/recordings/import-media-invalid-archive/file.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: import-validate-files-not-dir +expect: + exit_code: 0 diff --git a/testdata/parity/import-validate-sql-clean.yaml b/testdata/parity/import-validate-sql-clean.yaml new file mode 100644 index 000000000..c37d6e928 --- /dev/null +++ b/testdata/parity/import-validate-sql-clean.yaml @@ -0,0 +1,23 @@ +name: import-validate-sql-clean +description: | + validate-sql against a clean WP single-site dump. The handler emits + the per-check info block ("CREATE TABLE was found N times."). Node emits + no separate clean-summary line. No multi-site detection and no dangerous + statement findings. Exit 0. +argv: + - "import" + - "validate-sql" + - "../../testdata/parity/recordings/import-validate-sql-clean/clean.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: import-validate-sql-clean +expect: + exit_code: 0 +expected_drift: + signature: "a164dcbf1fa5daa501c89072df773892e19609886e3411dab68a752848d05a5e" + reason: >- + parity register 1.25 (KEEP): Node counts a phantom trailing line in SQL + files that end with a newline; vip-next reports the physical line count. +normalize: + stdout: [] diff --git a/testdata/parity/import-validate-sql-dangerous-stmt.yaml b/testdata/parity/import-validate-sql-dangerous-stmt.yaml new file mode 100644 index 000000000..8f38a06af --- /dev/null +++ b/testdata/parity/import-validate-sql-dangerous-stmt.yaml @@ -0,0 +1,27 @@ +name: import-validate-sql-dangerous-stmt +description: | + validate-sql against a dump containing a DROP DATABASE statement. + Node's `dropDB` check (sql.ts:271) flags this and the formatter emits + "DROP DATABASE statement on line(s) <n>." plus the "Remove these lines" + recommendation. Exit 1 per Node parity (validate() calls exit.withError + when problemsFound > 0, so CI pipelines like `vip import validate-sql + && deploy` short-circuit on findings). +argv: + - "import" + - "validate-sql" + - "../../testdata/parity/recordings/import-validate-sql-dangerous-stmt/dangerous.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: import-validate-sql-dangerous-stmt +expect: + exit_code: 1 +expected_drift: + signature: "ef3c31869a7f42f0ccc75cba1d039bb74dcdc11bcbe6418ba67a38de4439b844" + reason: >- + parity register 1.10 and 1.25 (KEEP): vip-next reports the physical line + count and a clean one-space Error prefix without Node's runtime Debug line; + the finding body and its stderr placement otherwise match Node. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' diff --git a/testdata/parity/import-validate-sql-multisite-warn.yaml b/testdata/parity/import-validate-sql-multisite-warn.yaml new file mode 100644 index 000000000..a8edb2acf --- /dev/null +++ b/testdata/parity/import-validate-sql-multisite-warn.yaml @@ -0,0 +1,23 @@ +name: import-validate-sql-multisite-warn +description: | + validate-sql against a dump containing `CREATE TABLE wp_2_options`, + which trips Node's SQL_CREATE_TABLE_IS_MULTISITE_REGEX. The handler reports + the `wp_n_ prefix tables found` count in the per-check info block; Node emits + no separate multi-site notice. Exit 0. +argv: + - "import" + - "validate-sql" + - "../../testdata/parity/recordings/import-validate-sql-multisite-warn/multisite.sql" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: import-validate-sql-multisite-warn +expect: + exit_code: 0 +expected_drift: + signature: "fe3a0981f9e4fb2bf4612dadbff33c096b8a320e5f2575ca459d69056f88f0e8" + reason: >- + parity register 1.25 (KEEP): Node counts a phantom trailing line in SQL + files that end with a newline; vip-next reports the physical line count. +normalize: + stdout: [] diff --git a/testdata/parity/logs-baseline.yaml b/testdata/parity/logs-baseline.yaml new file mode 100644 index 000000000..6305901fb --- /dev/null +++ b/testdata/parity/logs-baseline.yaml @@ -0,0 +1,24 @@ +name: logs-baseline +description: | + `vip @parityapp.develop logs` against a mock GraphQL server returning + two app log entries. Diff stdout/stderr/exit-code between Node vip and + Go vip-next. Verifies Node-parity table output with the default + type=app + limit=500 + format=table inputs. +argv: ["@parityapp.develop", "logs"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: logs-baseline +expect: + exit_code: 0 +expected_drift: + signature: "fdf5d1142e99bc66bd9d56f4bb27dd88232b54ca98e4e19f74f44205344072ad" + reason: >- + parity register section 3 (KEEP): `vip logs` renders lowercase "timestamp" + / "message" headers via the shared table renderer, where Node hand-rolls the + table in src/bin/vip-logs.js with head [ "Timestamp", "Message" ]. Frame, + widths, wrapping and cell contents match byte-for-byte; only the header + casing differs. +normalize: + stdout: [] diff --git a/testdata/parity/logs-batch.yaml b/testdata/parity/logs-batch.yaml new file mode 100644 index 000000000..6f6733943 --- /dev/null +++ b/testdata/parity/logs-batch.yaml @@ -0,0 +1,24 @@ +name: logs-batch +description: | + `vip @parityapp.develop logs --type=batch` returns batch-job log + entries (cron tasks, WP-CLI commands). Verifies Node parity on the + `type` flag and that the same table rendering applies regardless of + log stream. +argv: ["@parityapp.develop", "logs", "--type=batch"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: logs-batch +expect: + exit_code: 0 +expected_drift: + signature: "a8b366d140d6c8c647f205cd90dda8d3e085f65d75c454d6119f016e577894d6" + reason: >- + parity register section 3 (KEEP): `vip logs` renders lowercase "timestamp" + / "message" headers via the shared table renderer, where Node hand-rolls the + table in src/bin/vip-logs.js with head [ "Timestamp", "Message" ]. Frame, + widths, wrapping and cell contents match byte-for-byte; only the header + casing differs. +normalize: + stdout: [] diff --git a/testdata/parity/logs-empty.yaml b/testdata/parity/logs-empty.yaml new file mode 100644 index 000000000..ec273e1ec --- /dev/null +++ b/testdata/parity/logs-empty.yaml @@ -0,0 +1,15 @@ +name: logs-empty +description: | + `vip @parityapp.develop logs` against a server returning zero log + entries. Verifies the Node-parity behavior: "No logs found" on stderr + (Node uses console.error) + exit 0, no stdout output. +argv: ["@parityapp.develop", "logs"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: logs-empty +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/logs-format-json.yaml b/testdata/parity/logs-format-json.yaml new file mode 100644 index 000000000..647cb4ff5 --- /dev/null +++ b/testdata/parity/logs-format-json.yaml @@ -0,0 +1,15 @@ +name: logs-format-json +description: | + `vip @parityapp.develop logs --format=json` emits a top-level JSON + array of {timestamp, message} objects. Verifies Node-parity JSON + encoding (insertion-ordered keys, no header wrapper, no `__typename`). +argv: ["@parityapp.develop", "logs", "--format=json"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: logs-format-json +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/logs-limit-100.yaml b/testdata/parity/logs-limit-100.yaml new file mode 100644 index 000000000..a1da06f5a --- /dev/null +++ b/testdata/parity/logs-limit-100.yaml @@ -0,0 +1,24 @@ +name: logs-limit-100 +description: | + `vip @parityapp.develop logs --limit=100` requests a smaller batch. + The mock returns exactly two entries; the test verifies the limit + flag is forwarded as the GraphQL `limit` argument and that the + rendering path is unaffected. +argv: ["@parityapp.develop", "logs", "--limit=100"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: logs-limit-100 +expect: + exit_code: 0 +expected_drift: + signature: "0ea2758da6aa36c4caad4bbae2394dea10c5051f11ad5c9a3b7dff8c937a2d21" + reason: >- + parity register section 3 (KEEP): `vip logs` renders lowercase "timestamp" + / "message" headers via the shared table renderer, where Node hand-rolls the + table in src/bin/vip-logs.js with head [ "Timestamp", "Message" ]. Frame, + widths, wrapping and cell contents match byte-for-byte; only the header + casing differs. +normalize: + stdout: [] diff --git a/testdata/parity/phpmyadmin-error.yaml b/testdata/parity/phpmyadmin-error.yaml new file mode 100644 index 000000000..23aaca6eb --- /dev/null +++ b/testdata/parity/phpmyadmin-error.yaml @@ -0,0 +1,24 @@ +name: phpmyadmin-error +description: | + `vip @parityapp.develop db phpmyadmin --print` against an environment + whose status is unknown (no status.json in the recording), so the flow + enters the enable branch — and the enable mutation returns a GraphQL + error (e.g. Unauthorized). The CLI must exit non-zero and surface a + sensible error message on stderr. +argv: ["@parityapp.develop", "db", "phpmyadmin", "--print"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: phpmyadmin-error +expect: + exit_code: 1 +expected_drift: + signature: "574bd9a5ca09ffbbe366a192669e62efcd01849318d28fd11b06aa22cac38f85" + reason: >- + parity register 1.10 and 1.26 (KEEP): vip-next sends phpMyAdmin progress to + stderr and emits a clean error without Node's runtime Debug line; both map + the backend failure to a stable customer-facing message. +normalize: + stdout: + - '(?m)^Debug:.*$ -> Debug: <RUNTIME>' + stderr: [] diff --git a/testdata/parity/phpmyadmin-print.yaml b/testdata/parity/phpmyadmin-print.yaml new file mode 100644 index 000000000..d4eb7b7aa --- /dev/null +++ b/testdata/parity/phpmyadmin-print.yaml @@ -0,0 +1,22 @@ +name: phpmyadmin-print +description: | + `vip @parityapp.develop db phpmyadmin --print`. The recorded status is + already "running", so Node's maybeEnablePhpMyAdmin short-circuits + (src/commands/phpmyadmin.ts:213-222): status → generate, with NO enable + mutation. vip-next keeps stdout URL-only for scripting; Node's progress + tracker writes its warning and frames to stdout before `console.log( url )`. +argv: ["@parityapp.develop", "db", "phpmyadmin", "--print"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: phpmyadmin-print +expect: + exit_code: 0 +expected_drift: + signature: "879aafb8109cb49122e457224e5e9359dce7ab6b810e81d86360d6e41fb4f2f5" + reason: >- + parity register 1.26 (KEEP): vip-next keeps --print stdout URL-only and + sends human progress to stderr; Node interleaves progress and the URL on + stdout. +normalize: + stdout: [] diff --git a/testdata/parity/phpmyadmin-silent.yaml b/testdata/parity/phpmyadmin-silent.yaml new file mode 100644 index 000000000..8efc02264 --- /dev/null +++ b/testdata/parity/phpmyadmin-silent.yaml @@ -0,0 +1,16 @@ +name: phpmyadmin-silent +description: | + `vip @parityapp.develop db phpmyadmin --print --silent`. The URL must + still land on stdout (it's the documented output channel for --print), + but the progress lines and the yellow "read-only" warning must be + suppressed on stderr. +argv: ["@parityapp.develop", "db", "phpmyadmin", "--print", "--silent"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test +recording: phpmyadmin-silent +expect: + exit_code: 0 +normalize: + stdout: [] + stderr: [] diff --git a/testdata/parity/recordings/app-deploy-validate/.gitignore b/testdata/parity/recordings/app-deploy-validate/.gitignore new file mode 100644 index 000000000..bc3d6dd14 --- /dev/null +++ b/testdata/parity/recordings/app-deploy-validate/.gitignore @@ -0,0 +1,4 @@ +# Archive fixtures are generated by writeDeployFixtures in +# internal/parity/backup_export_deploy_scenario_test.go — never commit +# binaries. +*.tar.gz diff --git a/testdata/parity/recordings/app-get-baseline/app.json b/testdata/parity/recordings/app-get-baseline/app.json new file mode 100644 index 000000000..7167abf35 --- /dev/null +++ b/testdata/parity/recordings/app-get-baseline/app.json @@ -0,0 +1,11 @@ +{"data":{"apps":{"edges":[{ + "id":42,"name":"example-app","repo":"wpcomvip/example-app", + "environments":[ + {"id":7,"appId":42,"name":"develop","type":"develop","branch":"main", + "currentCommit":"abcdef1234567890","primaryDomain":{"name":"dev.example.com"}, + "launched":false,"deploymentStrategy":"git"}, + {"id":8,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"deadbee9999","primaryDomain":{"name":"www.example.com"}, + "launched":true,"deploymentStrategy":"git"} + ] +}]}}} diff --git a/testdata/parity/recordings/app-get-custom-deploy/app.json b/testdata/parity/recordings/app-get-custom-deploy/app.json new file mode 100644 index 000000000..a8baeffba --- /dev/null +++ b/testdata/parity/recordings/app-get-custom-deploy/app.json @@ -0,0 +1,8 @@ +{"data":{"apps":{"edges":[{ + "id":42,"name":"x","repo":"wpcomvip/x", + "environments":[ + {"id":9,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"1234567890abcdef","primaryDomain":{"name":"x.example.com"}, + "launched":true,"deploymentStrategy":"custom-deploy"} + ] +}]}}} diff --git a/testdata/parity/recordings/app-get-json/app.json b/testdata/parity/recordings/app-get-json/app.json new file mode 100644 index 000000000..7167abf35 --- /dev/null +++ b/testdata/parity/recordings/app-get-json/app.json @@ -0,0 +1,11 @@ +{"data":{"apps":{"edges":[{ + "id":42,"name":"example-app","repo":"wpcomvip/example-app", + "environments":[ + {"id":7,"appId":42,"name":"develop","type":"develop","branch":"main", + "currentCommit":"abcdef1234567890","primaryDomain":{"name":"dev.example.com"}, + "launched":false,"deploymentStrategy":"git"}, + {"id":8,"appId":42,"name":"production","type":"production","branch":"main", + "currentCommit":"deadbee9999","primaryDomain":{"name":"www.example.com"}, + "launched":true,"deploymentStrategy":"git"} + ] +}]}}} diff --git a/testdata/parity/recordings/app-get-not-found/app.json b/testdata/parity/recordings/app-get-not-found/app.json new file mode 100644 index 000000000..25f6ad831 --- /dev/null +++ b/testdata/parity/recordings/app-get-not-found/app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[]}}} diff --git a/testdata/parity/recordings/app-list-baseline/apps.json b/testdata/parity/recordings/app-list-baseline/apps.json new file mode 100644 index 000000000..5dbe20627 --- /dev/null +++ b/testdata/parity/recordings/app-list-baseline/apps.json @@ -0,0 +1 @@ +{"data":{"apps":{"total":2,"nextCursor":null,"edges":[{"id":42,"name":"example-app","repo":"wpcomvip/example-app"},{"id":43,"name":"example-multisite","repo":"wpcomvip/example-multisite"}]}}} diff --git a/testdata/parity/recordings/app-list-csv/apps.json b/testdata/parity/recordings/app-list-csv/apps.json new file mode 100644 index 000000000..5dbe20627 --- /dev/null +++ b/testdata/parity/recordings/app-list-csv/apps.json @@ -0,0 +1 @@ +{"data":{"apps":{"total":2,"nextCursor":null,"edges":[{"id":42,"name":"example-app","repo":"wpcomvip/example-app"},{"id":43,"name":"example-multisite","repo":"wpcomvip/example-multisite"}]}}} diff --git a/testdata/parity/recordings/app-list-empty/apps.json b/testdata/parity/recordings/app-list-empty/apps.json new file mode 100644 index 000000000..45aa191a4 --- /dev/null +++ b/testdata/parity/recordings/app-list-empty/apps.json @@ -0,0 +1 @@ +{"data":{"apps":{"total":0,"nextCursor":null,"edges":[]}}} diff --git a/testdata/parity/recordings/app-list-json/apps.json b/testdata/parity/recordings/app-list-json/apps.json new file mode 100644 index 000000000..5dbe20627 --- /dev/null +++ b/testdata/parity/recordings/app-list-json/apps.json @@ -0,0 +1 @@ +{"data":{"apps":{"total":2,"nextCursor":null,"edges":[{"id":42,"name":"example-app","repo":"wpcomvip/example-app"},{"id":43,"name":"example-multisite","repo":"wpcomvip/example-multisite"}]}}} diff --git a/testdata/parity/recordings/backup-db-already-in-progress/backup-status-1.json b/testdata/parity/recordings/backup-db-already-in-progress/backup-status-1.json new file mode 100644 index 000000000..d846a5340 --- /dev/null +++ b/testdata/parity/recordings/backup-db-already-in-progress/backup-status-1.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[{"__typename":"Job","id":1,"type":"db_backup","completedAt":"2026-06-11 10:00:00","createdAt":"2026-06-11 09:00:00","inProgressLock":true,"metadata":[{"name":"backupName","value":"backup-1"}],"progress":{"status":"running"}}]}]}}} diff --git a/testdata/parity/recordings/backup-db-already-in-progress/backup-status-2.json b/testdata/parity/recordings/backup-db-already-in-progress/backup-status-2.json new file mode 100644 index 000000000..5aa819199 --- /dev/null +++ b/testdata/parity/recordings/backup-db-already-in-progress/backup-status-2.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[{"__typename":"Job","id":1,"type":"db_backup","completedAt":"2026-06-11 10:00:00","createdAt":"2026-06-11 09:00:00","inProgressLock":false,"metadata":[{"name":"backupName","value":"backup-1"}],"progress":{"status":"success"}}]}]}}} diff --git a/testdata/parity/recordings/backup-db-completed/backup-status-1.json b/testdata/parity/recordings/backup-db-completed/backup-status-1.json new file mode 100644 index 000000000..4077ce6a8 --- /dev/null +++ b/testdata/parity/recordings/backup-db-completed/backup-status-1.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[]}]}}} diff --git a/testdata/parity/recordings/backup-db-completed/backup-status-2.json b/testdata/parity/recordings/backup-db-completed/backup-status-2.json new file mode 100644 index 000000000..d846a5340 --- /dev/null +++ b/testdata/parity/recordings/backup-db-completed/backup-status-2.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[{"__typename":"Job","id":1,"type":"db_backup","completedAt":"2026-06-11 10:00:00","createdAt":"2026-06-11 09:00:00","inProgressLock":true,"metadata":[{"name":"backupName","value":"backup-1"}],"progress":{"status":"running"}}]}]}}} diff --git a/testdata/parity/recordings/backup-db-completed/backup-status-3.json b/testdata/parity/recordings/backup-db-completed/backup-status-3.json new file mode 100644 index 000000000..5aa819199 --- /dev/null +++ b/testdata/parity/recordings/backup-db-completed/backup-status-3.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"jobs":[{"__typename":"Job","id":1,"type":"db_backup","completedAt":"2026-06-11 10:00:00","createdAt":"2026-06-11 09:00:00","inProgressLock":false,"metadata":[{"name":"backupName","value":"backup-1"}],"progress":{"status":"success"}}]}]}}} diff --git a/testdata/parity/recordings/cache-purge-url-empty/resolve-app.json b/testdata/parity/recordings/cache-purge-url-empty/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-empty/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/cache-purge-url-from-file/purge.json b/testdata/parity/recordings/cache-purge-url-from-file/purge.json new file mode 100644 index 000000000..c8daf9617 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-from-file/purge.json @@ -0,0 +1 @@ +{"data":{"purgePageCache":{"success":true,"urls":["https://example-app.go-vip.co/from-file-1/","https://example-app.go-vip.co/from-file-2/"]}}} diff --git a/testdata/parity/recordings/cache-purge-url-from-file/resolve-app.json b/testdata/parity/recordings/cache-purge-url-from-file/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-from-file/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/cache-purge-url-from-file/urls.txt b/testdata/parity/recordings/cache-purge-url-from-file/urls.txt new file mode 100644 index 000000000..b58c8d151 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-from-file/urls.txt @@ -0,0 +1,2 @@ +https://example-app.go-vip.co/from-file-1/ +https://example-app.go-vip.co/from-file-2/ diff --git a/testdata/parity/recordings/cache-purge-url-multi/purge.json b/testdata/parity/recordings/cache-purge-url-multi/purge.json new file mode 100644 index 000000000..d51353b47 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-multi/purge.json @@ -0,0 +1 @@ +{"data":{"purgePageCache":{"success":true,"urls":["https://example-app.go-vip.co/page-a/","https://example-app.go-vip.co/page-b/","https://example-app.go-vip.co/page-c/"]}}} diff --git a/testdata/parity/recordings/cache-purge-url-multi/resolve-app.json b/testdata/parity/recordings/cache-purge-url-multi/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-multi/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/cache-purge-url-single/purge.json b/testdata/parity/recordings/cache-purge-url-single/purge.json new file mode 100644 index 000000000..2ea09ad9a --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-single/purge.json @@ -0,0 +1 @@ +{"data":{"purgePageCache":{"success":true,"urls":["https://example-app.go-vip.co/sample-page/"]}}} diff --git a/testdata/parity/recordings/cache-purge-url-single/resolve-app.json b/testdata/parity/recordings/cache-purge-url-single/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/cache-purge-url-single/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/config-software-get/resolve-app.json b/testdata/parity/recordings/config-software-get/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/config-software-get/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/config-software-update/resolve-app.json b/testdata/parity/recordings/config-software-update/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/config-software-update/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-elevated.json b/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-elevated.json new file mode 100644 index 000000000..84e09680a --- /dev/null +++ b/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-elevated.json @@ -0,0 +1 @@ +{"errors":[{"message":"Step-up required.","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"/parker/sessions","statusPathTemplate":"/parker/sessions/{challengeId}","exchangePathTemplate":"/parker/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]} diff --git a/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-success.json b/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-success.json new file mode 100644 index 000000000..5c4197579 --- /dev/null +++ b/testdata/parity/recordings/defensive-mode-enable-rechallenge/mutation-success.json @@ -0,0 +1 @@ +{"data":{"updateDefensiveModeStatus":{"success":true,"message":"Defensive mode enabled."}}} diff --git a/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-create-session.json b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-create-session.json new file mode 100644 index 000000000..43c195c3e --- /dev/null +++ b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-create-session.json @@ -0,0 +1 @@ +{"challengeId":"c1","status":"pending","verificationUrl":"https://example.com/v/c1","pollIntervalSeconds":0,"expiresAt":"2099-01-01T00:00:00Z"} diff --git a/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-exchange.json b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-exchange.json new file mode 100644 index 000000000..292787083 --- /dev/null +++ b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-exchange.json @@ -0,0 +1 @@ +{"elevatedToken":{"token":"elev-token-xyz","expiresAt":"2099-01-01T00:00:00Z","purpose":"updateDefensiveModeStatus"}} diff --git a/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-status-verified.json b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-status-verified.json new file mode 100644 index 000000000..ad58f088e --- /dev/null +++ b/testdata/parity/recordings/defensive-mode-enable-rechallenge/parker-status-verified.json @@ -0,0 +1 @@ +{"challengeId":"c1","status":"verified","expiresAt":"2099-01-01T00:00:00Z","pollIntervalSeconds":0,"provider":"passkeys"} diff --git a/testdata/parity/recordings/envvar-delete-baseline/delete.json b/testdata/parity/recordings/envvar-delete-baseline/delete.json new file mode 100644 index 000000000..1671fb13c --- /dev/null +++ b/testdata/parity/recordings/envvar-delete-baseline/delete.json @@ -0,0 +1 @@ +{"data":{"deleteEnvironmentVariable":{"environmentVariables":{"total":0,"nodes":[]}}}} diff --git a/testdata/parity/recordings/envvar-delete-baseline/resolve-app.json b/testdata/parity/recordings/envvar-delete-baseline/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/envvar-delete-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-delete-prod-cancel/resolve-app.json b/testdata/parity/recordings/envvar-delete-prod-cancel/resolve-app.json new file mode 100644 index 000000000..71c8c8270 --- /dev/null +++ b/testdata/parity/recordings/envvar-delete-prod-cancel/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-delete-typed-mismatch/resolve-app.json b/testdata/parity/recordings/envvar-delete-typed-mismatch/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/envvar-delete-typed-mismatch/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-get-baseline/envvars.json b/testdata/parity/recordings/envvar-get-baseline/envvars.json new file mode 100644 index 000000000..1d1ad14ac --- /dev/null +++ b/testdata/parity/recordings/envvar-get-baseline/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO","value":"hello"},{"name":"BAR","value":"world"},{"name":"BAZ","value":"!"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-get-baseline/resolve-app.json b/testdata/parity/recordings/envvar-get-baseline/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-get-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-get-lowercase-input/envvars.json b/testdata/parity/recordings/envvar-get-lowercase-input/envvars.json new file mode 100644 index 000000000..1d87e287a --- /dev/null +++ b/testdata/parity/recordings/envvar-get-lowercase-input/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"hello"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-get-lowercase-input/resolve-app.json b/testdata/parity/recordings/envvar-get-lowercase-input/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-get-lowercase-input/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-get-named-help/envvars.json b/testdata/parity/recordings/envvar-get-named-help/envvars.json new file mode 100644 index 000000000..89ade7454 --- /dev/null +++ b/testdata/parity/recordings/envvar-get-named-help/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":1,"nodes":[{"name":"HELP","value":"not-a-bypass-token"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-get-named-help/resolve-app.json b/testdata/parity/recordings/envvar-get-named-help/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-get-named-help/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-get-not-found/envvars.json b/testdata/parity/recordings/envvar-get-not-found/envvars.json new file mode 100644 index 000000000..1d1ad14ac --- /dev/null +++ b/testdata/parity/recordings/envvar-get-not-found/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO","value":"hello"},{"name":"BAR","value":"world"},{"name":"BAZ","value":"!"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-get-not-found/resolve-app.json b/testdata/parity/recordings/envvar-get-not-found/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-get-not-found/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-baseline/envvars.json b/testdata/parity/recordings/envvar-getall-baseline/envvars.json new file mode 100644 index 000000000..1d1ad14ac --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-baseline/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO","value":"hello"},{"name":"BAR","value":"world"},{"name":"BAZ","value":"!"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-baseline/resolve-app.json b/testdata/parity/recordings/envvar-getall-baseline/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-empty/envvars.json b/testdata/parity/recordings/envvar-getall-empty/envvars.json new file mode 100644 index 000000000..42ca668fb --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-empty/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":0,"nodes":[]}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-empty/resolve-app.json b/testdata/parity/recordings/envvar-getall-empty/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-empty/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-keyvalue/envvars.json b/testdata/parity/recordings/envvar-getall-keyvalue/envvars.json new file mode 100644 index 000000000..1d1ad14ac --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-keyvalue/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO","value":"hello"},{"name":"BAR","value":"world"},{"name":"BAZ","value":"!"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-getall-keyvalue/resolve-app.json b/testdata/parity/recordings/envvar-getall-keyvalue/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-getall-keyvalue/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-list-baseline/envvars.json b/testdata/parity/recordings/envvar-list-baseline/envvars.json new file mode 100644 index 000000000..c324571fb --- /dev/null +++ b/testdata/parity/recordings/envvar-list-baseline/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO"},{"name":"BAR"},{"name":"BAZ"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-list-baseline/resolve-app.json b/testdata/parity/recordings/envvar-list-baseline/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-list-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-list-empty/envvars.json b/testdata/parity/recordings/envvar-list-empty/envvars.json new file mode 100644 index 000000000..42ca668fb --- /dev/null +++ b/testdata/parity/recordings/envvar-list-empty/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":0,"nodes":[]}}]}}} diff --git a/testdata/parity/recordings/envvar-list-empty/resolve-app.json b/testdata/parity/recordings/envvar-list-empty/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-list-empty/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-list-ids/envvars.json b/testdata/parity/recordings/envvar-list-ids/envvars.json new file mode 100644 index 000000000..c324571fb --- /dev/null +++ b/testdata/parity/recordings/envvar-list-ids/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO"},{"name":"BAR"},{"name":"BAZ"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-list-ids/resolve-app.json b/testdata/parity/recordings/envvar-list-ids/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-list-ids/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-list-json/envvars.json b/testdata/parity/recordings/envvar-list-json/envvars.json new file mode 100644 index 000000000..c324571fb --- /dev/null +++ b/testdata/parity/recordings/envvar-list-json/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO"},{"name":"BAR"},{"name":"BAZ"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-list-json/resolve-app.json b/testdata/parity/recordings/envvar-list-json/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-list-json/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-list-keyvalue/envvars.json b/testdata/parity/recordings/envvar-list-keyvalue/envvars.json new file mode 100644 index 000000000..c324571fb --- /dev/null +++ b/testdata/parity/recordings/envvar-list-keyvalue/envvars.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"environmentVariables":{"total":3,"nodes":[{"name":"FOO"},{"name":"BAR"},{"name":"BAZ"}]}}]}}} diff --git a/testdata/parity/recordings/envvar-list-keyvalue/resolve-app.json b/testdata/parity/recordings/envvar-list-keyvalue/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/envvar-list-keyvalue/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/envvar-set-baseline/add.json b/testdata/parity/recordings/envvar-set-baseline/add.json new file mode 100644 index 000000000..ce51dd042 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-baseline/add.json @@ -0,0 +1 @@ +{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"MY_VAR"}]}}}} diff --git a/testdata/parity/recordings/envvar-set-baseline/resolve-app.json b/testdata/parity/recordings/envvar-set-baseline/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-set-baseline/value.txt b/testdata/parity/recordings/envvar-set-baseline/value.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-baseline/value.txt @@ -0,0 +1 @@ +hello diff --git a/testdata/parity/recordings/envvar-set-invalid-name/resolve-app.json b/testdata/parity/recordings/envvar-set-invalid-name/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-invalid-name/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-set-invalid-name/value.txt b/testdata/parity/recordings/envvar-set-invalid-name/value.txt new file mode 100644 index 000000000..fd19246d6 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-invalid-name/value.txt @@ -0,0 +1 @@ +irrelevant diff --git a/testdata/parity/recordings/envvar-set-newrelic-blocked/resolve-app.json b/testdata/parity/recordings/envvar-set-newrelic-blocked/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-newrelic-blocked/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-set-newrelic-blocked/value.txt b/testdata/parity/recordings/envvar-set-newrelic-blocked/value.txt new file mode 100644 index 000000000..fd19246d6 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-newrelic-blocked/value.txt @@ -0,0 +1 @@ +irrelevant diff --git a/testdata/parity/recordings/envvar-set-prod-cancel/resolve-app.json b/testdata/parity/recordings/envvar-set-prod-cancel/resolve-app.json new file mode 100644 index 000000000..71c8c8270 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-prod-cancel/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-set-prod-cancel/value.txt b/testdata/parity/recordings/envvar-set-prod-cancel/value.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-prod-cancel/value.txt @@ -0,0 +1 @@ +hello diff --git a/testdata/parity/recordings/envvar-set-prod-confirm-skipped/add.json b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/add.json new file mode 100644 index 000000000..ce51dd042 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/add.json @@ -0,0 +1 @@ +{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"MY_VAR"}]}}}} diff --git a/testdata/parity/recordings/envvar-set-prod-confirm-skipped/resolve-app.json b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/resolve-app.json new file mode 100644 index 000000000..71c8c8270 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/envvar-set-prod-confirm-skipped/value.txt b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/value.txt new file mode 100644 index 000000000..ce0136250 --- /dev/null +++ b/testdata/parity/recordings/envvar-set-prod-confirm-skipped/value.txt @@ -0,0 +1 @@ +hello diff --git a/testdata/parity/recordings/export-sql-completed/export-status-1.json b/testdata/parity/recordings/export-sql-completed/export-status-1.json new file mode 100644 index 000000000..dfda9bf6a --- /dev/null +++ b/testdata/parity/recordings/export-sql-completed/export-status-1.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"backupsSqlDumpTool":"mysqldump","latestBackup":{"id":11,"type":"daily","size":1024,"filename":"backup.sql.gz","sqlDumpTool":"mysqldump","createdAt":"2026-06-11 10:00:00"},"jobs":[]}]}}} diff --git a/testdata/parity/recordings/export-sql-completed/export-status-2.json b/testdata/parity/recordings/export-sql-completed/export-status-2.json new file mode 100644 index 000000000..c90a9c3f7 --- /dev/null +++ b/testdata/parity/recordings/export-sql-completed/export-status-2.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"backupsSqlDumpTool":"mysqldump","latestBackup":{"id":11,"type":"daily","size":1024,"filename":"backup.sql.gz","sqlDumpTool":"mysqldump","createdAt":"2026-06-11 10:00:00"},"jobs":[{"__typename":"Job","id":5,"type":"db_backup_copy","completedAt":null,"createdAt":"2026-06-11 10:05:00","inProgressLock":false,"metadata":[{"name":"backupId","value":"11"},{"name":"bytesWritten","value":"17"}],"progress":{"status":"running","steps":[{"id":"preflight","name":"Preflight","step":"preflight","status":"success"},{"id":"upload_backup","name":"Upload","step":"upload_backup","status":"success"}]}}]}]}}} diff --git a/testdata/parity/recordings/import-media-invalid-archive/file.sql b/testdata/parity/recordings/import-media-invalid-archive/file.sql new file mode 100644 index 000000000..060019916 --- /dev/null +++ b/testdata/parity/recordings/import-media-invalid-archive/file.sql @@ -0,0 +1 @@ +not an archive \ No newline at end of file diff --git a/testdata/parity/recordings/import-media-shared/abort-media-import.json b/testdata/parity/recordings/import-media-shared/abort-media-import.json new file mode 100644 index 000000000..82209f487 --- /dev/null +++ b/testdata/parity/recordings/import-media-shared/abort-media-import.json @@ -0,0 +1 @@ +{"data":{"abortMediaImport":{"applicationId":42,"environmentId":7,"mediaImportStatusChange":{"importId":1,"siteId":7,"statusFrom":"RUNNING","statusTo":"ABORTING"}}}} diff --git a/testdata/parity/recordings/import-media-shared/env-info.json b/testdata/parity/recordings/import-media-shared/env-info.json new file mode 100644 index 000000000..dfa76bd92 --- /dev/null +++ b/testdata/parity/recordings/import-media-shared/env-info.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true,"primaryDomain":{"name":"example.com"},"importStatus":{"dbOperationInProgress":false,"importInProgress":false},"wpSitesSDS":{"nodes":[]}}]}}} diff --git a/testdata/parity/recordings/import-media-shared/resolve-app.json b/testdata/parity/recordings/import-media-shared/resolve-app.json new file mode 100644 index 000000000..578d461e5 --- /dev/null +++ b/testdata/parity/recordings/import-media-shared/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","type":"WordPress","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/import-media-shared/start-media-import.json b/testdata/parity/recordings/import-media-shared/start-media-import.json new file mode 100644 index 000000000..7a37d052e --- /dev/null +++ b/testdata/parity/recordings/import-media-shared/start-media-import.json @@ -0,0 +1 @@ +{"data":{"startMediaImport":{"applicationId":42,"environmentId":7,"mediaImportStatus":{"importId":1,"siteId":7,"status":"INITIALIZING"}}}} diff --git a/testdata/parity/recordings/import-media-status-completed/progress.json b/testdata/parity/recordings/import-media-status-completed/progress.json new file mode 100644 index 000000000..bdd82156f --- /dev/null +++ b/testdata/parity/recordings/import-media-status-completed/progress.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"id":7,"name":"develop","type":"develop","repo":"r","mediaImportStatus":{"importId":1,"siteId":7,"status":"COMPLETED","filesTotal":10,"filesProcessed":10}}]}}} diff --git a/testdata/parity/recordings/import-media-status-failed/progress.json b/testdata/parity/recordings/import-media-status-failed/progress.json new file mode 100644 index 000000000..145b9f181 --- /dev/null +++ b/testdata/parity/recordings/import-media-status-failed/progress.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"id":7,"name":"develop","type":"develop","repo":"r","mediaImportStatus":{"importId":1,"siteId":7,"status":"FAILED","filesTotal":10,"filesProcessed":3,"failureDetails":{"previousStatus":"RUNNING","globalErrors":["disk full"],"fileErrorsUrl":null}}}]}}} diff --git a/testdata/parity/recordings/import-media-url-completed/progress.json b/testdata/parity/recordings/import-media-url-completed/progress.json new file mode 100644 index 000000000..bdd82156f --- /dev/null +++ b/testdata/parity/recordings/import-media-url-completed/progress.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"id":7,"name":"develop","type":"develop","repo":"r","mediaImportStatus":{"importId":1,"siteId":7,"status":"COMPLETED","filesTotal":10,"filesProcessed":10}}]}}} diff --git a/testdata/parity/recordings/import-sql-bad-extension/file.txt b/testdata/parity/recordings/import-sql-bad-extension/file.txt new file mode 100644 index 000000000..e0ac49d1e --- /dev/null +++ b/testdata/parity/recordings/import-sql-bad-extension/file.txt @@ -0,0 +1 @@ +SELECT 1; diff --git a/testdata/parity/recordings/import-sql-in-progress/env-info.json b/testdata/parity/recordings/import-sql-in-progress/env-info.json new file mode 100644 index 000000000..b463ef05e --- /dev/null +++ b/testdata/parity/recordings/import-sql-in-progress/env-info.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true,"primaryDomain":{"name":"example.com"},"importStatus":{"dbOperationInProgress":false,"importInProgress":true},"wpSitesSDS":{"nodes":[]}}]}}} diff --git a/testdata/parity/recordings/import-sql-in-progress/resolve-app.json b/testdata/parity/recordings/import-sql-in-progress/resolve-app.json new file mode 100644 index 000000000..62672e62b --- /dev/null +++ b/testdata/parity/recordings/import-sql-in-progress/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","type":"WordPress","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example","launched":false,"isK8sResident":true,"primaryDomain":{"name":"example.com"},"syncProgress":{"status":"success"},"importStatus":{"dbOperationInProgress":false,"importInProgress":true}}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/import-sql-noninteractive-abort/clean.sql b/testdata/parity/recordings/import-sql-noninteractive-abort/clean.sql new file mode 100644 index 000000000..57957d401 --- /dev/null +++ b/testdata/parity/recordings/import-sql-noninteractive-abort/clean.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`option_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/testdata/parity/recordings/import-sql-shared/env-info.json b/testdata/parity/recordings/import-sql-shared/env-info.json new file mode 100644 index 000000000..dfa76bd92 --- /dev/null +++ b/testdata/parity/recordings/import-sql-shared/env-info.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"type":"develop","name":"develop","launched":false,"isK8sResident":true,"primaryDomain":{"name":"example.com"},"importStatus":{"dbOperationInProgress":false,"importInProgress":false},"wpSitesSDS":{"nodes":[]}}]}}} diff --git a/testdata/parity/recordings/import-sql-shared/multisite.json b/testdata/parity/recordings/import-sql-shared/multisite.json new file mode 100644 index 000000000..0c6fbbe45 --- /dev/null +++ b/testdata/parity/recordings/import-sql-shared/multisite.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"name":"parityapp","repo":"r","environments":[{"id":7,"appId":42,"name":"develop","type":"develop","isMultisite":false,"isSubdirectoryMultisite":false}]}}} diff --git a/testdata/parity/recordings/import-sql-shared/resolve-app.json b/testdata/parity/recordings/import-sql-shared/resolve-app.json new file mode 100644 index 000000000..4f4bcdfdf --- /dev/null +++ b/testdata/parity/recordings/import-sql-shared/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","type":"WordPress","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example","launched":false,"isK8sResident":true,"primaryDomain":{"name":"example.com"},"syncProgress":{"status":"success"},"importStatus":{"dbOperationInProgress":false,"importInProgress":false}}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/import-sql-status-completed/progress.json b/testdata/parity/recordings/import-sql-status-completed/progress.json new file mode 100644 index 000000000..4433c21bc --- /dev/null +++ b/testdata/parity/recordings/import-sql-status-completed/progress.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"id":7,"isK8sResident":true,"launched":false,"jobs":[{"__typename":"Job","id":1,"type":"sql_import","createdAt":"Mon, 01 Jun 2026 00:00:00 UTC","completedAt":"Mon, 01 Jun 2026 00:05:00 UTC","progress":{"status":"success","steps":[{"id":"import_preflights","name":"Import preflights","status":"success"},{"id":"import","name":"Importing db","status":"success"},{"id":"validate","name":"Validating db","status":"success"}]}}],"importStatus":{"dbOperationInProgress":false,"importInProgress":false,"progress":null}}]}}} diff --git a/testdata/parity/recordings/import-sql-status-no-job/progress.json b/testdata/parity/recordings/import-sql-status-no-job/progress.json new file mode 100644 index 000000000..12c19f35d --- /dev/null +++ b/testdata/parity/recordings/import-sql-status-no-job/progress.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"id":7,"isK8sResident":true,"launched":false,"jobs":[],"importStatus":{"dbOperationInProgress":false,"importInProgress":false,"progress":null}}]}}} diff --git a/testdata/parity/recordings/import-sql-validation-failure/dirty.sql b/testdata/parity/recordings/import-sql-validation-failure/dirty.sql new file mode 100644 index 000000000..7b89e674c --- /dev/null +++ b/testdata/parity/recordings/import-sql-validation-failure/dirty.sql @@ -0,0 +1,6 @@ +DROP DATABASE production; +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`option_id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4; diff --git a/testdata/parity/recordings/import-validate-files-clean/config.json b/testdata/parity/recordings/import-validate-files-clean/config.json new file mode 100644 index 000000000..af48ae705 --- /dev/null +++ b/testdata/parity/recordings/import-validate-files-clean/config.json @@ -0,0 +1 @@ +{"data":{"mediaImportConfig":{"fileNameCharCount":255,"fileSizeLimitInBytes":1073741824,"allowedFileTypes":{"jpg":"image/jpeg","png":"image/png"}}}} diff --git a/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/a.jpg b/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/a.jpg new file mode 100644 index 000000000..c1b0730e0 --- /dev/null +++ b/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/a.jpg @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/b.png b/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/b.png new file mode 100644 index 000000000..c1b0730e0 --- /dev/null +++ b/testdata/parity/recordings/import-validate-files-clean/uploads/2020/06/b.png @@ -0,0 +1 @@ +x \ No newline at end of file diff --git a/testdata/parity/recordings/import-validate-sql-clean/clean.sql b/testdata/parity/recordings/import-validate-sql-clean/clean.sql new file mode 100644 index 000000000..0554419e8 --- /dev/null +++ b/testdata/parity/recordings/import-validate-sql-clean/clean.sql @@ -0,0 +1,17 @@ +-- A clean WP single-site dump fixture used by the parity harness. +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `option_name` varchar(191) NOT NULL DEFAULT '', + `option_value` longtext NOT NULL, + PRIMARY KEY (`option_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT INTO `wp_options` (`option_name`, `option_value`) VALUES ('siteurl', 'http://example.com'); +INSERT INTO `wp_options` (`option_name`, `option_value`) VALUES ('home', 'http://example.com'); +DROP TABLE IF EXISTS `wp_users`; +CREATE TABLE `wp_users` ( + `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user_login` varchar(60) NOT NULL DEFAULT '', + PRIMARY KEY (`ID`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT INTO `wp_users` (`user_login`) VALUES ('alice'); diff --git a/testdata/parity/recordings/import-validate-sql-dangerous-stmt/dangerous.sql b/testdata/parity/recordings/import-validate-sql-dangerous-stmt/dangerous.sql new file mode 100644 index 000000000..15d02aaa8 --- /dev/null +++ b/testdata/parity/recordings/import-validate-sql-dangerous-stmt/dangerous.sql @@ -0,0 +1,11 @@ +-- Dangerous-statement fixture: DROP DATABASE is flagged by sql.ts's +-- `dropDB` check (line 271). Includes the required DROP TABLE + +-- CREATE TABLE so those checks don't double-fault on missing-statement +-- errors; this scenario is about asserting the dangerous-finding wording +-- surfaces, not about clean-vs-dirty. +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`option_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +DROP DATABASE foo; diff --git a/testdata/parity/recordings/import-validate-sql-multisite-warn/multisite.sql b/testdata/parity/recordings/import-validate-sql-multisite-warn/multisite.sql new file mode 100644 index 000000000..87d9a8cff --- /dev/null +++ b/testdata/parity/recordings/import-validate-sql-multisite-warn/multisite.sql @@ -0,0 +1,12 @@ +-- WP multisite dump fixture: the wp_2_options table triggers the +-- multi-site CREATE-TABLE detection in is-multi-site-sql-dump.ts. +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`option_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +DROP TABLE IF EXISTS `wp_2_options`; +CREATE TABLE `wp_2_options` ( + `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + PRIMARY KEY (`option_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/testdata/parity/recordings/logs-baseline/logs.json b/testdata/parity/recordings/logs-baseline/logs.json new file mode 100644 index 000000000..abee02077 --- /dev/null +++ b/testdata/parity/recordings/logs-baseline/logs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","message":"hello world"},{"timestamp":"2024-01-01T00:00:01.000000000Z","message":"second line"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/logs-baseline/resolve-app.json b/testdata/parity/recordings/logs-baseline/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/logs-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/logs-batch/logs.json b/testdata/parity/recordings/logs-batch/logs.json new file mode 100644 index 000000000..7ab934b38 --- /dev/null +++ b/testdata/parity/recordings/logs-batch/logs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","message":"batch job 1 complete"},{"timestamp":"2024-01-01T00:00:01.000000000Z","message":"batch job 2 starting"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/logs-batch/resolve-app.json b/testdata/parity/recordings/logs-batch/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/logs-batch/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/logs-empty/logs.json b/testdata/parity/recordings/logs-empty/logs.json new file mode 100644 index 000000000..ca1bcca49 --- /dev/null +++ b/testdata/parity/recordings/logs-empty/logs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"logs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/logs-empty/resolve-app.json b/testdata/parity/recordings/logs-empty/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/logs-empty/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/logs-format-json/logs.json b/testdata/parity/recordings/logs-format-json/logs.json new file mode 100644 index 000000000..81b36104c --- /dev/null +++ b/testdata/parity/recordings/logs-format-json/logs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","message":"json line one"},{"timestamp":"2024-01-01T00:00:01.000000000Z","message":"json line two"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/logs-format-json/resolve-app.json b/testdata/parity/recordings/logs-format-json/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/logs-format-json/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/logs-limit-100/logs.json b/testdata/parity/recordings/logs-limit-100/logs.json new file mode 100644 index 000000000..3bb49caf2 --- /dev/null +++ b/testdata/parity/recordings/logs-limit-100/logs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","message":"capped at 100"},{"timestamp":"2024-01-01T00:00:01.000000000Z","message":"second of the 100"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/logs-limit-100/resolve-app.json b/testdata/parity/recordings/logs-limit-100/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/logs-limit-100/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/m7c-shared/resolve-app.json b/testdata/parity/recordings/m7c-shared/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/m7c-shared/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/phpmyadmin-error/enable.json b/testdata/parity/recordings/phpmyadmin-error/enable.json new file mode 100644 index 000000000..afc61daa1 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-error/enable.json @@ -0,0 +1 @@ +{"errors":[{"message":"Unauthorized"}],"data":null} diff --git a/testdata/parity/recordings/phpmyadmin-error/resolve-app.json b/testdata/parity/recordings/phpmyadmin-error/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-error/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/phpmyadmin-print/enable.json b/testdata/parity/recordings/phpmyadmin-print/enable.json new file mode 100644 index 000000000..ac9674dff --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-print/enable.json @@ -0,0 +1 @@ +{"data":{"enablePHPMyAdmin":{"success":true}}} diff --git a/testdata/parity/recordings/phpmyadmin-print/generate.json b/testdata/parity/recordings/phpmyadmin-print/generate.json new file mode 100644 index 000000000..5383d22fd --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-print/generate.json @@ -0,0 +1 @@ +{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.parity.example/abc"}}} diff --git a/testdata/parity/recordings/phpmyadmin-print/resolve-app.json b/testdata/parity/recordings/phpmyadmin-print/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-print/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/phpmyadmin-print/status.json b/testdata/parity/recordings/phpmyadmin-print/status.json new file mode 100644 index 000000000..8b574ec08 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-print/status.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}} diff --git a/testdata/parity/recordings/phpmyadmin-silent/enable.json b/testdata/parity/recordings/phpmyadmin-silent/enable.json new file mode 100644 index 000000000..ac9674dff --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-silent/enable.json @@ -0,0 +1 @@ +{"data":{"enablePHPMyAdmin":{"success":true}}} diff --git a/testdata/parity/recordings/phpmyadmin-silent/generate.json b/testdata/parity/recordings/phpmyadmin-silent/generate.json new file mode 100644 index 000000000..8076d7241 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-silent/generate.json @@ -0,0 +1 @@ +{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.parity.example/silent"}}} diff --git a/testdata/parity/recordings/phpmyadmin-silent/resolve-app.json b/testdata/parity/recordings/phpmyadmin-silent/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-silent/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/phpmyadmin-silent/status.json b/testdata/parity/recordings/phpmyadmin-silent/status.json new file mode 100644 index 000000000..8b574ec08 --- /dev/null +++ b/testdata/parity/recordings/phpmyadmin-silent/status.json @@ -0,0 +1 @@ +{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}} diff --git a/testdata/parity/recordings/slowlogs-baseline/resolve-app.json b/testdata/parity/recordings/slowlogs-baseline/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/slowlogs-baseline/slowlogs.json b/testdata/parity/recordings/slowlogs-baseline/slowlogs.json new file mode 100644 index 000000000..e5fceae0f --- /dev/null +++ b/testdata/parity/recordings/slowlogs-baseline/slowlogs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","rowsSent":"10","rowsExamined":"1000","queryTime":"1.234","requestUri":"/wp-admin/edit.php","query":"SELECT * FROM wp_posts WHERE post_status = 'publish'"},{"timestamp":"2024-01-01T00:00:01.000000000Z","rowsSent":"5","rowsExamined":"500","queryTime":"0.567","requestUri":"/wp-login.php","query":"SELECT * FROM wp_users WHERE user_login = 'admin'"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/slowlogs-csv/resolve-app.json b/testdata/parity/recordings/slowlogs-csv/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-csv/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/slowlogs-csv/slowlogs.json b/testdata/parity/recordings/slowlogs-csv/slowlogs.json new file mode 100644 index 000000000..dc663c271 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-csv/slowlogs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","rowsSent":"2","rowsExamined":"200","queryTime":"0.123","requestUri":"/api/v1/posts","query":"SELECT id, title FROM wp_posts"},{"timestamp":"2024-01-01T00:00:01.000000000Z","rowsSent":"1","rowsExamined":"100","queryTime":"0.456","requestUri":"/wp-cron.php","query":"DELETE FROM wp_options WHERE option_name = 'transient_x'"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/slowlogs-empty/resolve-app.json b/testdata/parity/recordings/slowlogs-empty/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-empty/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/slowlogs-empty/slowlogs.json b/testdata/parity/recordings/slowlogs-empty/slowlogs.json new file mode 100644 index 000000000..178ce67e2 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-empty/slowlogs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"slowlogs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/slowlogs-limit-50/resolve-app.json b/testdata/parity/recordings/slowlogs-limit-50/resolve-app.json new file mode 100644 index 000000000..6600ec2b5 --- /dev/null +++ b/testdata/parity/recordings/slowlogs-limit-50/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} diff --git a/testdata/parity/recordings/slowlogs-limit-50/slowlogs.json b/testdata/parity/recordings/slowlogs-limit-50/slowlogs.json new file mode 100644 index 000000000..fa52da7bd --- /dev/null +++ b/testdata/parity/recordings/slowlogs-limit-50/slowlogs.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00.000000000Z","rowsSent":"50","rowsExamined":"5000","queryTime":"3.456","requestUri":"/wp-cron.php","query":"SELECT * FROM wp_postmeta WHERE meta_key = 'view_count'"}],"nextCursor":null,"pollingDelaySeconds":30}}]}}} diff --git a/testdata/parity/recordings/sync-already-syncing/resolve-app.json b/testdata/parity/recordings/sync-already-syncing/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/sync-already-syncing/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/sync-already-syncing/sync-start.json b/testdata/parity/recordings/sync-already-syncing/sync-start.json new file mode 100644 index 000000000..d38e720e2 --- /dev/null +++ b/testdata/parity/recordings/sync-already-syncing/sync-start.json @@ -0,0 +1 @@ +{"data":null,"errors":[{"message":"Site is already syncing"}]} diff --git a/testdata/parity/recordings/sync-already-syncing/sync-status-1.json b/testdata/parity/recordings/sync-already-syncing/sync-status-1.json new file mode 100644 index 000000000..b8c6bf329 --- /dev/null +++ b/testdata/parity/recordings/sync-already-syncing/sync-status-1.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"syncProgress":{"status":"running","sync":99,"steps":[{"name":"Backup","status":"running","step":"backup"}]}}]}}} diff --git a/testdata/parity/recordings/sync-already-syncing/sync-status-2.json b/testdata/parity/recordings/sync-already-syncing/sync-status-2.json new file mode 100644 index 000000000..56f99bb62 --- /dev/null +++ b/testdata/parity/recordings/sync-already-syncing/sync-status-2.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"syncProgress":{"status":"success","sync":99,"steps":[{"name":"Backup","status":"success","step":"backup"}]}}]}}} diff --git a/testdata/parity/recordings/sync-baseline/resolve-app.json b/testdata/parity/recordings/sync-baseline/resolve-app.json new file mode 100644 index 000000000..6e68f65b9 --- /dev/null +++ b/testdata/parity/recordings/sync-baseline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/sync-baseline/sync-start.json b/testdata/parity/recordings/sync-baseline/sync-start.json new file mode 100644 index 000000000..6dc7fe36d --- /dev/null +++ b/testdata/parity/recordings/sync-baseline/sync-start.json @@ -0,0 +1 @@ +{"data":{"syncEnvironment":{"environment":{"id":7}}}} diff --git a/testdata/parity/recordings/sync-baseline/sync-status-1.json b/testdata/parity/recordings/sync-baseline/sync-status-1.json new file mode 100644 index 000000000..83c37d70d --- /dev/null +++ b/testdata/parity/recordings/sync-baseline/sync-status-1.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"syncProgress":{"status":"running","sync":99,"steps":[{"name":"Backup","status":"running","step":"backup"},{"name":"Restore","status":"pending","step":"restore"}]}}]}}} diff --git a/testdata/parity/recordings/sync-baseline/sync-status-2.json b/testdata/parity/recordings/sync-baseline/sync-status-2.json new file mode 100644 index 000000000..87d19a02b --- /dev/null +++ b/testdata/parity/recordings/sync-baseline/sync-status-2.json @@ -0,0 +1 @@ +{"data":{"app":{"id":42,"environments":[{"id":7,"syncProgress":{"status":"success","sync":99,"steps":[{"name":"Backup","status":"success","step":"backup"},{"name":"Restore","status":"success","step":"restore"}]}}]}}} diff --git a/testdata/parity/recordings/whoami-baseline/me-response.json b/testdata/parity/recordings/whoami-baseline/me-response.json new file mode 100644 index 000000000..740b567e2 --- /dev/null +++ b/testdata/parity/recordings/whoami-baseline/me-response.json @@ -0,0 +1 @@ +{"data":{"me":{"id":42,"displayName":"Parity Test User","trackingUserId":"42","isVIP":true,"organizationRoles":{"nodes":[]}}}} diff --git a/testdata/parity/recordings/wp-nodejs-rejected/resolve-app.json b/testdata/parity/recordings/wp-nodejs-rejected/resolve-app.json new file mode 100644 index 000000000..d34231bad --- /dev/null +++ b/testdata/parity/recordings/wp-nodejs-rejected/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":3,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/wp-production-confirm-decline/resolve-app.json b/testdata/parity/recordings/wp-production-confirm-decline/resolve-app.json new file mode 100644 index 000000000..b6bedfa01 --- /dev/null +++ b/testdata/parity/recordings/wp-production-confirm-decline/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","environments":[{"id":1,"appId":42,"name":"production","type":"production","defaultDomain":"p.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/wp-ssh-happy/resolve-app.json b/testdata/parity/recordings/wp-ssh-happy/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/wp-ssh-happy/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/recordings/wp-websocket-redirect/resolve-app.json b/testdata/parity/recordings/wp-websocket-redirect/resolve-app.json new file mode 100644 index 000000000..5df8d6907 --- /dev/null +++ b/testdata/parity/recordings/wp-websocket-redirect/resolve-app.json @@ -0,0 +1 @@ +{"data":{"apps":{"edges":[{"id":42,"name":"parityapp","typeId":2,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}],"organization":{"id":1,"name":"Parity Org"}}]}}} \ No newline at end of file diff --git a/testdata/parity/slowlogs-baseline.yaml b/testdata/parity/slowlogs-baseline.yaml new file mode 100644 index 000000000..071be4695 --- /dev/null +++ b/testdata/parity/slowlogs-baseline.yaml @@ -0,0 +1,18 @@ +name: slowlogs-baseline +description: | + `vip @parityapp.develop slowlogs` against a mock GraphQL server + returning two slow-query entries. Diff stdout/stderr/exit-code between + Node vip and Go vip-next. Verifies Node-parity table output with the + default limit=500 + format=table inputs and the slowlog-specific + column ordering (timestamp, rowsSent, rowsExamined, queryTime, + requestUri, query). +argv: ["@parityapp.develop", "slowlogs"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: slowlogs-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/slowlogs-csv.yaml b/testdata/parity/slowlogs-csv.yaml new file mode 100644 index 000000000..c2b188c6b --- /dev/null +++ b/testdata/parity/slowlogs-csv.yaml @@ -0,0 +1,16 @@ +name: slowlogs-csv +description: | + `vip @parityapp.develop slowlogs --format=csv` emits CSV with the + Node-parity column order (timestamp, rowsSent, rowsExamined, + queryTime, requestUri, query). Verifies that quoting handles SQL + strings containing commas and quotes. +argv: ["@parityapp.develop", "slowlogs", "--format=csv"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: slowlogs-csv +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/slowlogs-empty.yaml b/testdata/parity/slowlogs-empty.yaml new file mode 100644 index 000000000..ae8e43ba9 --- /dev/null +++ b/testdata/parity/slowlogs-empty.yaml @@ -0,0 +1,17 @@ +name: slowlogs-empty +description: | + `vip @parityapp.develop slowlogs` against a server returning zero + slow-query entries. Verifies the Node-parity behavior: "No logs + found" on stderr (Node uses console.error) + exit 0, no stdout + output. Yes — the wording says "logs" rather than "slowlogs"; that + matches src/bin/vip-slowlogs.ts. +argv: ["@parityapp.develop", "slowlogs"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: slowlogs-empty +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/slowlogs-limit-50.yaml b/testdata/parity/slowlogs-limit-50.yaml new file mode 100644 index 000000000..a34a2827f --- /dev/null +++ b/testdata/parity/slowlogs-limit-50.yaml @@ -0,0 +1,16 @@ +name: slowlogs-limit-50 +description: | + `vip @parityapp.develop slowlogs --limit=50` requests a smaller + batch (max 500 vs logs' 5000). Verifies the limit flag forwards to + the GraphQL `limit` argument and that the smaller ceiling enforced + in validateSlowlogsInputs doesn't reject 50. +argv: ["@parityapp.develop", "slowlogs", "--limit=50"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: slowlogs-limit-50 +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/sync-already-syncing.yaml b/testdata/parity/sync-already-syncing.yaml new file mode 100644 index 000000000..f9b4c069a --- /dev/null +++ b/testdata/parity/sync-already-syncing.yaml @@ -0,0 +1,20 @@ +name: sync-already-syncing +description: | + `vip @parityapp.develop sync --skip-confirmation` when the server + rejects the mutation with "Site is already syncing". The handler + recognizes the sentinel error, prints the yellow Note, and proceeds + to polling — which terminates on success and exits 0 (Node parity: + the GraphQL error is NOT fatal in this specific case). +argv: + - "@parityapp.develop" + - "sync" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + VIP_SYNC_INTERVAL_MS: "5" +recording: sync-already-syncing +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/sync-baseline.yaml b/testdata/parity/sync-baseline.yaml new file mode 100644 index 000000000..5045840f4 --- /dev/null +++ b/testdata/parity/sync-baseline.yaml @@ -0,0 +1,19 @@ +name: sync-baseline +description: | + `vip @parityapp.develop sync --skip-confirmation`. Non-prod target + (develop), --skip-confirmation bypasses the "Are you sure..." prompt, + the mutation succeeds, then the poll loop transitions from "running" + to "success" and exits 0. +argv: + - "@parityapp.develop" + - "sync" + - "--skip-confirmation" +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + VIP_SYNC_INTERVAL_MS: "5" +recording: sync-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/version-smoke.yaml b/testdata/parity/version-smoke.yaml new file mode 100644 index 000000000..b2d9a8d14 --- /dev/null +++ b/testdata/parity/version-smoke.yaml @@ -0,0 +1,14 @@ +name: version-smoke +description: | + Self-diff smoke test: run vip-next --version against two builds with + different version metadata and assert identical normalized output and + exit code. Proves the harness pipeline works end-to-end before M2 + introduces real Node-vs-Go diffs. +argv: ["--version"] +env: + DO_NOT_TRACK: "1" +expect: + exit_code: 0 +normalize: + stdout: + - "vip-next [^ ]+ \\(commit [^)]+\\) -> vip-next <VERSION> (commit <COMMIT>)" diff --git a/testdata/parity/whoami-baseline.yaml b/testdata/parity/whoami-baseline.yaml new file mode 100644 index 000000000..d6590b12e --- /dev/null +++ b/testdata/parity/whoami-baseline.yaml @@ -0,0 +1,14 @@ +name: whoami-baseline +description: | + Run `whoami` against a mock GraphQL server that returns a fixed Me + response. Diff stdout/stderr/exit-code between Node vip and Go vip-next. + First real Node-vs-Go parity scenario. +argv: ["whoami"] +env: + NODE_ENV: test + DO_NOT_TRACK: "1" +recording: whoami-baseline +expect: + exit_code: 0 +normalize: + stdout: [] diff --git a/testdata/parity/wp-help.yaml b/testdata/parity/wp-help.yaml new file mode 100644 index 000000000..0993bc8ac --- /dev/null +++ b/testdata/parity/wp-help.yaml @@ -0,0 +1,12 @@ +name: wp-help +description: | + `vip help wp` prints the wp command's help text. No network round-trips. + Exit 0. (Note: `vip wp --help` passes --help as a raw WP-CLI arg due to + DisableFlagParsing; `vip help wp` uses cobra's built-in help path instead.) +argv: ["help", "wp"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +expect: + exit_code: 0 diff --git a/testdata/parity/wp-nodejs-rejected.yaml b/testdata/parity/wp-nodejs-rejected.yaml new file mode 100644 index 000000000..bd0ea9eb1 --- /dev/null +++ b/testdata/parity/wp-nodejs-rejected.yaml @@ -0,0 +1,13 @@ +name: wp-nodejs-rejected +description: | + WPEnvInfo returns typeId:3 (Node.js environment). The command must reject + with "WP-CLI commands are not supported on Node.js environments." and exit 1. + TriggerWPCLICommand must NOT fire. +argv: ["@parityapp.develop", "wp", "site", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: wp-nodejs-rejected +expect: + exit_code: 1 diff --git a/testdata/parity/wp-production-confirm-decline.yaml b/testdata/parity/wp-production-confirm-decline.yaml new file mode 100644 index 000000000..f6b6fd6e1 --- /dev/null +++ b/testdata/parity/wp-production-confirm-decline.yaml @@ -0,0 +1,14 @@ +name: wp-production-confirm-decline +description: | + Production environment + VIP_NON_INTERACTIVE=1, no --yes flag. The production + confirmation prompt cannot prompt so it declines: "Command cancelled". Exit 0. + TriggerWPCLICommand must NOT fire. +argv: ["@parityapp.production", "wp", "user", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" + VIP_NON_INTERACTIVE: "1" +recording: wp-production-confirm-decline +expect: + exit_code: 0 diff --git a/testdata/parity/wp-ssh-happy.yaml b/testdata/parity/wp-ssh-happy.yaml new file mode 100644 index 000000000..2b0642e6e --- /dev/null +++ b/testdata/parity/wp-ssh-happy.yaml @@ -0,0 +1,15 @@ +name: wp-ssh-happy +description: | + Develop environment, ssh strategy. TriggerWPCLICommand returns SSH auth + credentials pointing at an in-process echo server (constructed dynamically + in the test — not from a static recording file). The echo server writes the + exec preamble to stdout. Exit 0. Output contains "GUID=parity-guid-001". + TriggerWPCLICommand fires exactly once. +argv: ["@parityapp.develop", "--yes", "wp", "option", "get", "home"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: wp-ssh-happy +expect: + exit_code: 0 diff --git a/testdata/parity/wp-websocket-redirect.yaml b/testdata/parity/wp-websocket-redirect.yaml new file mode 100644 index 000000000..85eb1996b --- /dev/null +++ b/testdata/parity/wp-websocket-redirect.yaml @@ -0,0 +1,14 @@ +name: wp-websocket-redirect +description: | + WPEnvInfo returns wpcliStrategy:"websocket" for a develop environment. + The command must redirect with "requires the Node CLI" and exit 1. + TriggerWPCLICommand must NOT fire. This is the intended WP1 behavior, + not drift. +argv: ["@parityapp.develop", "wp", "site", "list"] +env: + DO_NOT_TRACK: "1" + NODE_ENV: test + NO_COLOR: "1" +recording: wp-websocket-redirect +expect: + exit_code: 1 diff --git a/third_party/go-search-replace/MANIFEST b/third_party/go-search-replace/MANIFEST new file mode 100644 index 000000000..71ba9ec1b --- /dev/null +++ b/third_party/go-search-replace/MANIFEST @@ -0,0 +1,34 @@ +# go-search-replace — pinned upstream release +# +# vip-next shells out to this binary; it never reimplements it. See +# internal/searchreplace/searchreplace.go (ResolveBinary) and +# docs/BUILD-SIGNING.md. +# +# Source: https://github.com/Automattic/go-search-replace/releases +# +# The digests below are NOT computed by us. They are the subject digests from +# the release's SLSA provenance attestation (go-search-replace.intoto.jsonl), +# produced by: +# +# https://github.com/Automattic/go-search-replace/.github/workflows/release.yml@refs/tags/0.0.11 +# +# IMPORTANT: upstream ships each asset gzipped (<name>.gz) but the provenance +# subjects are the UNCOMPRESSED binaries. Verify by gunzipping first, then +# sha256. `make vendor-search-replace` does exactly that and refuses to install +# anything that does not match. +# +# To upgrade: `make vendor-search-replace TAG=<new-tag>` rewrites this file, so +# an upgrade is one reviewable commit whose diff is the tag and the digests. +# +# Format: <goos>/<goarch> <sha256-of-uncompressed-binary> + +TAG 0.0.11 + +darwin/amd64 84c06c7372f8485ee62d51f0ae1cfa580432830e2d756d728b8e8b3415fd49eb +darwin/arm64 dad680dbd24af5c455d2af49ba97563f07f5f86f9b609be8c91abd38c4772448 +linux/386 de65a0bcdc0907c5a5ede2804e602330819e497258679dad1676a8931d484a06 +linux/amd64 d5b5a3a5e9b76bf5bd07d579ae931f192ba01236f92ef54898b8f0d7d5548109 +linux/arm64 df32ee7aa1bc611a6bfe6bc2945abc0f755219da441299ffd2d9add64da95bda +windows/386 92d32fbcb6ea0548f8b70f8d6e57d0efd01b849800f3d814c369a8f65b19583b +windows/amd64 db7b116593b5369e033c43bd46a207e8fc9761ef87c5f16cbb3abd34c8d8a858 +windows/arm64 e26c342cc95bc0a28656d30b52f2f1c1e1c034a6d672690bdb7e9ffcc334eb4f