diff --git a/.gitignore b/.gitignore index cf952945..620714cb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ log # Research output files outputs/ +# Managed cht-core checkout (scripts/test-env-up.sh with no path) +.cht-core/ + # Pipeline skip-audit log. Every real run appends to it, so tracking it made each # run produce a commit and let a stray test entry reach real pipeline data (#146). agent-memory/_skipped.ndjson diff --git a/designs/layer_recommendations/test-environment-layer.md b/designs/layer_recommendations/test-environment-layer.md index 0699af35..b6522ebe 100644 --- a/designs/layer_recommendations/test-environment-layer.md +++ b/designs/layer_recommendations/test-environment-layer.md @@ -223,7 +223,7 @@ Default: **CouchDB wipe + reseed** for config/data iterations. Under Model A, ** #### Teardown -`docker compose down -v` (the `-v` clears volumes for a clean slate). Always run on completion or failure to avoid orphaned containers. +`scripts/test-env-down.sh` (`docker compose down -v`). `-v` removes the named volumes, but CouchDB data is a bind mount under `local-build/` and survives, so a clean database also means deleting that directory. Always run on completion or failure to avoid orphaned containers. ### 2. Config Discovery diff --git a/docker/cht-agent-net.override.yml b/docker/cht-agent-net.override.yml new file mode 100644 index 00000000..45536549 --- /dev/null +++ b/docker/cht-agent-net.override.yml @@ -0,0 +1,39 @@ +# cht-agent-net.override.yml +# +# Layers onto cht-core's local-build compose (or the docker-helper stack) so the +# running CHT instance joins the shared `cht-agent-net` network that the +# dockerized cht-agent is also attached to. This lets the agent reach the +# instance at https://nginx with no host-port juggling and — importantly — with +# NO Docker access (the agent only polls /api/v2/monitoring). +# +# HUMAN-run only (the cht-agent never runs Docker). scripts/test-env-up.sh applies +# it with a per-checkout project, internal network and data dir (scripts/lib/test-env.sh); +# by hand, pass the same values that test_env_compose passes, not only -p. Without +# CHT_NETWORK, the stack shares the default `cht-net` network and the couchdb/haproxy/api +# names with any other CHT stack that also uses `cht-net`. Without COUCHDB_PASSWORD, +# Compose stops. Use this command: +# COUCHDB_USER=medic COUCHDB_PASSWORD=password COMMON_NAME=nginx \ +# NGINX_HTTP_PORT=127.0.0.1:80 NGINX_HTTPS_PORT=127.0.0.1:443 \ +# CHT_NETWORK=-net COUCHDB_DATA=./srv- \ +# docker compose -p -f cht-couchdb.yml -f cht-core.yml -f cht-agent-net.override.yml up -d +# Only one stack's nginx can be on cht-agent-net at a time: `nginx` would resolve to both. +# +# VALIDATE before relying on this: the service name (`nginx`) and the key of the +# stack's internal network (`cht-net` below; CHT_NETWORK sets its actual name) must +# match what `npm run local-images` generates in `local-build/` for your cht-core +# version. Confirm with: +# docker compose ... config # see resolved service/network names +# docker network inspect cht-agent-net # confirm nginx is attached +# If the internal network differs, update the first entry under nginx.networks. + +networks: + cht-agent-net: + external: true + +services: + # nginx is the HTTPS entry point the agent reaches. Keep it on the stack's + # internal network AND join the shared cht-agent-net. + nginx: + networks: + - cht-net + - cht-agent-net diff --git a/docker/docker-compose.cht-agent.yml b/docker/docker-compose.cht-agent.yml index 733e9c23..f4f0af05 100644 --- a/docker/docker-compose.cht-agent.yml +++ b/docker/docker-compose.cht-agent.yml @@ -48,6 +48,8 @@ services: # .credentials.json mount below (for OAuth-based tools). ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} CHT_CORE_PATH: /workspace/cht-core + # The same working copy on the host, where the operator runs the printed gates. + CHT_CORE_HOST_PATH: ${CHT_CORE_PATH} # CHT entry point on cht-agent-net (nginx joins via the #66 override) CHT_URL: ${CHT_URL:-https://nginx} NODE_ENV: development diff --git a/docs/docker-agent-runbook.md b/docs/docker-agent-runbook.md index 0fbaca84..5ef01ac1 100644 --- a/docs/docker-agent-runbook.md +++ b/docs/docker-agent-runbook.md @@ -129,17 +129,41 @@ When the Test Environment Layer (#66) requests an environment, the agent *waits and polls* — you bring it up: ```bash -# Build images from the agent's edited working copy (Model A rebuild-on-change) -cd ~/src/cht-core && npm run local-images - -# Start the stack, attaching nginx to cht-agent-net via the #66 override -docker compose -f \ - -f /docker/cht-agent-net.override.yml up -d +# From the cht-agent repo: build the agent's edited working copy into images +# (Model A rebuild-on-change) and start it, nginx joined to cht-agent-net +CHT_CORE_REBUILD=1 scripts/test-env-up.sh ~/src/cht-core ``` -> The override file `docker/cht-agent-net.override.yml` ships with #66. -> Validate service/network names against your generated compose before -> relying on it (see the comments in that file). +> `scripts/test-env-up.sh` gives each checkout its own Compose project, internal +> network and CouchDB data dir, and refuses to start while another stack's nginx +> is on `cht-agent-net`. Overrides are listed in `scripts/lib/test-env.sh`. +> +> The stack's cert is self-signed, and the agent's readiness poll stops at the first +> TLS verification error. So bring the stack up before the agent run starts, not +> after the agent asks: the poll then succeeds at once. Run the `docker cp` line +> that `test-env-up.sh` prints at its end, then copy that file into the container +> (`docker cp cht-agent:/tmp/cht-cert.pem`). Start the run with +> `docker exec -e NODE_EXTRA_CA_CERTS=/tmp/cht-cert.pem ...`. After +> `test-env-down.sh`, the next bring-up makes a new cert. If the bring-up used +> non-default `COUCHDB_USER` or `COUCHDB_PASSWORD`, pass the same values with `docker exec -e`. + +Settings the layer reads from the environment: + +| Variable | Read by | Effect (default) | +|---|---|---| +| `CHT_URL` | agent | Instance `provision` dials when the call passes no `url` (`https://nginx`). | +| `COUCHDB_USER` / `COUCHDB_PASSWORD` | agent, scripts | Stack admin; each defaults on its own (`medic` / `password`). The agent refuses the default password against a host it does not treat as disposable. | +| `CHT_TEST_ENV_ALLOW_EXTERNAL=1` | agent | Allows a host it does not treat as disposable, like `allowExternalTarget`. | +| `CHT_CONF_BIN` | agent | cht-conf binary to spawn (`cht`). | +| `CHT_CORE_HOST_PATH` | agent | Host path of the working copy, printed in the gates. The agent's compose file sets it to `$CHT_CORE_PATH`. | +| `CHT_TEST_ENV_PROJECT` | scripts, agent | Compose project (`cht-agent--`). The agent repeats it in the gates it prints. | +| `CHT_CORE_PATH` | scripts | Working copy, when no path argument is given. | +| `CHT_CORE_CLONE_DIR` | scripts | Managed checkout when neither is given (`/.cht-core`), cloned from `CHT_CORE_UPSTREAM` at `CHT_CORE_BRANCH` (medic/cht-core, master). | +| `CHT_CORE_REBUILD=1` | scripts | Runs `npm run build-dev` even when a build exists. | +| `COUCHDB_DATA` | scripts | CouchDB bind mount (`local-build/srv-`). It survives `test-env-down.sh`. | +| `NGINX_HTTP_PORT` / `NGINX_HTTPS_PORT` | scripts | Host binds (`127.0.0.1:80` / `127.0.0.1:443`). Opening them to the LAN needs a non-default `COUCHDB_PASSWORD`. | +| `COMMON_NAME` | scripts | Cert CN (`nginx`, the host the agent dials). | +| `NODE_EXTRA_CA_CERTS` | Node | The stack's `cert.pem`, read once per process (see above). | The agent detects readiness via `GET https://nginx/api/v2/monitoring` and continues. CouchDB-tier resets it does itself over HTTP; container restarts @@ -161,7 +185,7 @@ git push origin cht-agent/ ```bash docker compose -f docker/docker-compose.cht-agent.yml down -# CHT stack: docker compose -f down -v +scripts/test-env-down.sh ~/src/cht-core # CHT stack # Full cleanup of the shared network (only once nothing else uses it): docker network rm cht-agent-net ``` @@ -173,5 +197,5 @@ docker network rm cht-agent-net | compose fails mounting `git-config.hardened` over `.git/config` | working copy is a git worktree (`.git` is a file) or missing. Run `docker/scripts/bootstrap-workspace.sh` on the host. | | `Sandbox verification FAILED` in `docker logs` | a hardening layer is missing — read which `[FAIL]` fired; don't bypass it. | | working-copy files unwritable from container | host uid ≠ 1000. Rebuild with a matching uid or chown the copy. | -| agent can't reach `https://nginx` | CHT stack not attached to `cht-agent-net` — re-run compose with the #66 override; `docker network inspect cht-agent-net` should list nginx. | +| agent can't reach `https://nginx` | CHT stack not attached to `cht-agent-net` — bring it up with `scripts/test-env-up.sh`; `docker network inspect cht-agent-net` should list one nginx. | | `fetch` fails for an `ssh://` or `git@` URL | by design (no ssh binary). Use the `https://` URL. | diff --git a/scripts/lib/test-env.sh b/scripts/lib/test-env.sh new file mode 100644 index 00000000..649160b9 --- /dev/null +++ b/scripts/lib/test-env.sh @@ -0,0 +1,91 @@ +# shellcheck shell=bash +# Shared by scripts/test-env-{up,down,restart}.sh (sourced, not run). +# +# Every cht-core checkout builds in a directory named `local-build`, and Compose +# names a project after its directory, so without -p all checkouts share one project +# and `down -v` on one deletes another's stack. Each checkout therefore gets its own +# project, and with it its own internal network (the couchdb/api/haproxy names), +# CouchDB bind mount, and cert volume. Only nginx joins the shared cht-agent-net. +# +# Env overrides: +# CHT_TEST_ENV_PROJECT Compose project (default cht-agent--) +# COUCHDB_USER / COUCHDB_PASSWORD admin (default medic / password, the agent's DEFAULT_AUTH) +# COUCHDB_DATA CouchDB bind mount (default local-build/srv-) +# NGINX_HTTP_PORT / NGINX_HTTPS_PORT host binds (default 127.0.0.1:80 / 127.0.0.1:443; +# set 443 to reach the stack from another device, and then also set +# COUCHDB_PASSWORD: the default admin is medic / password) +# COMMON_NAME cert CN (default nginx, the host name the containerized agent uses) + +TEST_ENV_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEST_ENV_OVERRIDE="$TEST_ENV_REPO_ROOT/docker/cht-agent-net.override.yml" +# Must match the external network in docker/cht-agent-net.override.yml. +# shellcheck disable=SC2034 # used by test-env-up.sh +TEST_ENV_NETWORK="cht-agent-net" + +test_env_default_target() { + local path="${1:-}" + printf '%s\n' "${path:-${CHT_CORE_PATH:-${CHT_CORE_CLONE_DIR:-$TEST_ENV_REPO_ROOT/.cht-core}}}" + return $? +} + +test_env_hash() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum; else shasum -a 256; fi | cut -c1-8 + return $? +} + +# Sets TEST_ENV_TARGET (physical path) and TEST_ENV_PROJECT for an existing checkout. +test_env_select() { + local path="$1" base + TEST_ENV_TARGET="$(cd "$path" && pwd -P)" + base="$(basename "$TEST_ENV_TARGET" | LC_ALL=C tr '[:upper:]' '[:lower:]' | LC_ALL=C sed -e 's/[^a-z0-9_-]/-/g' -e 's/^-*//')" + TEST_ENV_PROJECT="${CHT_TEST_ENV_PROJECT:-cht-agent-${base}-$(printf '%s' "$TEST_ENV_TARGET" | test_env_hash)}" + return $? +} + +test_env_require_build() { + local path="$1" + if [[ ! -d "$path/local-build" ]]; then + echo "error: no cht-core build at $path (pass a path or set CHT_CORE_PATH)" >&2 + exit 1 + fi +} + +# COUCHDB_PASSWORD goes to every subcommand: the compose files declare ${COUCHDB_PASSWORD:?...}. +test_env_compose() { + ( cd "$TEST_ENV_TARGET/local-build" && + COUCHDB_USER="${COUCHDB_USER:-medic}" COUCHDB_PASSWORD="${COUCHDB_PASSWORD:-password}" \ + CHT_NETWORK="$TEST_ENV_PROJECT-net" COUCHDB_DATA="${COUCHDB_DATA:-./srv-$TEST_ENV_PROJECT}" \ + NGINX_HTTP_PORT="${NGINX_HTTP_PORT:-127.0.0.1:80}" NGINX_HTTPS_PORT="${NGINX_HTTPS_PORT:-127.0.0.1:443}" \ + COMMON_NAME="${COMMON_NAME:-nginx}" \ + docker compose -p "$TEST_ENV_PROJECT" -f cht-couchdb.yml -f cht-core.yml -f "$TEST_ENV_OVERRIDE" "$@" ) + return $? +} + +# Refuse a project that another checkout started. With a shared CHT_TEST_ENV_PROJECT, +# up would recreate that checkout's containers, and down -v would delete them and its volumes. +test_env_require_owner() { + local dirs dir + dirs="$(docker ps -a --filter "label=com.docker.compose.project=$TEST_ENV_PROJECT" \ + --format '{{.Label "com.docker.compose.project.working_dir"}}' | sort -u)" + while IFS= read -r dir; do + if [[ -n "$dir" && "$dir" != "$TEST_ENV_TARGET/local-build" ]]; then + echo "error: Compose project '$TEST_ENV_PROJECT' has containers from $dir, which is not $TEST_ENV_TARGET/local-build." >&2 + exit 1 + fi + done <<< "$dirs" + return 0 +} + +# restart and down would otherwise report success against a project with nothing in it. +test_env_require_containers() { + local containers + containers="$(test_env_compose ps -a -q)" + if [[ -z "$containers" ]]; then + echo "error: Compose project '$TEST_ENV_PROJECT' has no containers." >&2 + echo " 'docker compose ls -a' lists the projects that exist; a stack this checkout" >&2 + echo " started under another name needs CHT_TEST_ENV_PROJECT=." >&2 + exit 1 + fi + test_env_require_owner + return 0 +} diff --git a/scripts/test-env-down.sh b/scripts/test-env-down.sh new file mode 100755 index 00000000..bcb58dd3 --- /dev/null +++ b/scripts/test-env-down.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# +# Human-run teardown for the Test Environment Layer. +# The cht-agent NEVER runs this or any Docker command itself. +# Usage: scripts/test-env-down.sh [] (overrides: scripts/lib/test-env.sh) +set -euo pipefail +source "$(dirname "$0")/lib/test-env.sh" + +# Same resolution as test-env-up.sh, minus the clone: the stack must already exist. +TARGET="$(test_env_default_target "${1:-}")" +test_env_require_build "$TARGET" +test_env_select "$TARGET" +test_env_require_containers +test_env_compose down -v + +echo "CHT environment '$TEST_ENV_PROJECT' torn down. -v removed its named volumes; CouchDB data in the" \ + "${COUCHDB_DATA:-$TEST_ENV_TARGET/local-build/srv-$TEST_ENV_PROJECT} bind mount stays." diff --git a/scripts/test-env-restart.sh b/scripts/test-env-restart.sh new file mode 100755 index 00000000..811cc2da --- /dev/null +++ b/scripts/test-env-restart.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# Human-run restart for the Test Environment Layer (reset tier: restart). +# The cht-agent NEVER runs this or any Docker command itself. +# Usage: scripts/test-env-restart.sh [] (overrides: scripts/lib/test-env.sh) +set -euo pipefail +source "$(dirname "$0")/lib/test-env.sh" + +# Same resolution as test-env-up.sh, minus the clone: the stack must already exist. +TARGET="$(test_env_default_target "${1:-}")" +test_env_require_build "$TARGET" +test_env_select "$TARGET" +test_env_require_containers +test_env_compose restart + +echo "CHT services in '$TEST_ENV_PROJECT' restarted. The agent should re-confirm health (provision/waitForReady)." diff --git a/scripts/test-env-up.sh b/scripts/test-env-up.sh new file mode 100755 index 00000000..5b959a4a --- /dev/null +++ b/scripts/test-env-up.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Human-run bring-up for the Test Environment Layer (Model A — rebuild on change). +# The cht-agent NEVER runs this or any Docker command itself; the agent's +# provision() only polls /api/v2/monitoring and waits for this to finish. The +# clone/install below is this HUMAN script's convenience — the agent still never +# clones, installs, or runs Docker. +# +# Usage: scripts/test-env-up.sh [] +# +# With no argument the stack is built from $CHT_CORE_PATH, or from a managed +# checkout at $CHT_CORE_CLONE_DIR (default /.cht-core), cloned from master +# on first use. A path you pass is used as-is and is never cloned into. +# +# Project, credential, port and cert overrides: see scripts/lib/test-env.sh. +# +# TLS: the stack serves a SAN-less self-signed cert whose CN is COMMON_NAME (nginx). +# The agent's cht-conf child accepts it via --accept-self-signed-certs; the agent's +# own fetch (readiness/discovery/reset) needs it trusted via NODE_EXTRA_CA_CERTS +# (copy command printed at the end). NODE_TLS_REJECT_UNAUTHORIZED=0 disables +# verification for ALL of the agent's traffic (LLM/MCP included) and is acceptable +# only inside a disposable runner container. +set -euo pipefail +source "$(dirname "$0")/lib/test-env.sh" + +CLONE_DIR="${CHT_CORE_CLONE_DIR:-$TEST_ENV_REPO_ROOT/.cht-core}" +CHT_CORE_UPSTREAM="${CHT_CORE_UPSTREAM:-https://github.com/medic/cht-core.git}" +CHT_CORE_BRANCH="${CHT_CORE_BRANCH:-master}" + +# cht-core's own engines require Node >= 22.15; npm ci and the image build both +# fail in confusing ways on older runtimes. +NODE_MAJOR="$(node -v 2>/dev/null | sed 's/^v\([0-9]*\).*/\1/')" +if [[ -z "$NODE_MAJOR" ]] || [[ "$NODE_MAJOR" -lt 22 ]]; then + echo "error: cht-core needs Node >= 22.15 (found: $(node -v 2>/dev/null || echo 'no node')). Try 'nvm use 22'." >&2 + exit 1 +fi + +TARGET="${1:-${CHT_CORE_PATH:-}}" +if [[ -z "$TARGET" ]]; then + TARGET="$CLONE_DIR" + if [[ ! -d "$TARGET/.git" ]]; then + echo "No cht-core path given — cloning $CHT_CORE_BRANCH into $TARGET (shallow)." + echo "Pass a path, or set CHT_CORE_PATH, to build from a working copy instead." + # Shallow is safe: cht-core derives its image version from the branch name + # (cht-core's scripts/build/versions.js), not from git tags. + git clone --depth 1 --branch "$CHT_CORE_BRANCH" "$CHT_CORE_UPSTREAM" "$TARGET" + fi +fi + +if [[ ! -d "$TARGET" ]]; then + echo "error: cht-core path not found: $TARGET" >&2 + exit 1 +fi +test_env_select "$TARGET" +test_env_require_owner + +# 1. Shared network the cht-agent and CHT both join. +docker network inspect "$TEST_ENV_NETWORK" >/dev/null 2>&1 || docker network create "$TEST_ENV_NETWORK" >/dev/null + +# The agent reaches CHT as https://nginx on that network; a second stack's nginx +# there would split the name between two instances. -a: a stopped nginx comes back +# when the Docker daemon restarts (restart: always). +nginx_projects="$(docker ps -a --filter "network=$TEST_ENV_NETWORK" --filter label=com.docker.compose.service=nginx \ + --format '{{.Label "com.docker.compose.project"}}')" +while IFS= read -r project; do + if [[ -n "$project" && "$project" != "$TEST_ENV_PROJECT" ]]; then + echo "error: Compose project '$project' already has an nginx on $TEST_ENV_NETWORK." >&2 + echo " Tear it down first: CHT_TEST_ENV_PROJECT='$project' scripts/test-env-down.sh " >&2 + echo " If that checkout is gone: docker rm -f \$(docker ps -aq --filter label=com.docker.compose.project=$project)" >&2 + exit 1 + fi +done <<< "$nginx_projects" + +# `npm run local-images` builds from node_modules (bowser, uglifyjs, cleancss); +# without them it dies on an opaque `cp: cannot stat` deep inside the build. +if [[ ! -d "$TARGET/node_modules" ]]; then + echo "Installing cht-core dependencies in $TARGET (npm ci — several minutes, ~1.2GB)." + # Lifecycle scripts must run: cht-core's postinstall is patch-package, and skipping + # it leaves the patches unapplied and the build broken. This is why --ignore-scripts + # is not used here. The code being installed is the same tree we are about to build + # images from and run, so npm scripts add no privilege beyond what follows. + ( cd "$TARGET" && npm ci ) +elif [[ ! -e "$TARGET/node_modules/bowser/bundled.js" ]] || [[ ! -x "$TARGET/node_modules/.bin/uglifyjs" ]]; then + echo "error: cht-core dependencies in $TARGET look incomplete (the image build needs" >&2 + echo " bowser + uglifyjs). Run 'npm ci' there yourself — this script will not" >&2 + echo " replace an existing node_modules." >&2 + exit 1 +fi + +# 2. Build the app. `npm run local-images` only PACKAGES an already-built tree: +# build-service-images.sh copies into api/build/static/, which is created by +# build-prepare.sh (ddocs, enketo css, admin app) and filled by build-webapp-dev. +# npm ci alone does not produce it — build-dev also runs the per-module installs. +if [[ ! -d "$TARGET/api/build/static" ]] || [[ "${CHT_CORE_REBUILD:-}" = "1" ]]; then + echo "Building cht-core in $TARGET (npm run build-dev — several minutes)." + ( cd "$TARGET" && npm run build-dev ) +else + echo "Reusing the existing cht-core build in $TARGET." + echo "Set CHT_CORE_REBUILD=1 to rebuild after changing cht-core source (Model A is rebuild-on-change)." +fi + +# 3. Package those build outputs into local Docker images. +( cd "$TARGET" && npm run local-images ) + +# 4. Start the stack, joined to the shared network via the override. +test_env_compose up -d + +echo "CHT starting as Compose project '$TEST_ENV_PROJECT' on '$TEST_ENV_NETWORK'. The agent will poll /api/v2/monitoring until healthy." +echo "To trust its cert: docker cp $TEST_ENV_PROJECT-nginx-1:/etc/nginx/private/cert.pem , then NODE_EXTRA_CA_CERTS= for the agent." +echo "Node reads that file once per process: start the agent after this cert exists, and restart it after test-env-down.sh (the next up makes a new cert)." diff --git a/src/agents/test-environment-agent.mock-data.ts b/src/agents/test-environment-agent.mock-data.ts index 0481e3be..e2978dfd 100644 --- a/src/agents/test-environment-agent.mock-data.ts +++ b/src/agents/test-environment-agent.mock-data.ts @@ -1,4 +1,5 @@ -import { DiscoveredConfig, TestDataResult } from '../types'; +import { ConfigActionResult, ConfigUploadAction, DiscoveredConfig, TestDataResult } from '../types'; +import { CONFIG_ACTION_COMMANDS } from '../utils/cht-conf-runner'; /** * Raw deterministic fixtures for the Test Environment Agent's mock mode. @@ -15,9 +16,19 @@ export type MockTestEnvData = { testData: TestDataResult; }; +/** + * Build a successful (uploaded), warning-free mock result for a single bucket. + */ +export const mockConfigActionResult = (action: ConfigUploadAction): ConfigActionResult => ({ + action, + status: 'uploaded', + commands: [...CONFIG_ACTION_COMMANDS[action]], + warnings: [], +}); + export const MOCK_TEST_ENV_DATA: MockTestEnvData = { url: 'https://nginx', - // matches the defaults set by cht-core's scripts/docker-helper/cht-docker-compose.sh + // matches the creds scripts/test-env-up.sh brings the stack up with (and the agent's DEFAULT_AUTH) auth: { user: 'medic', password: 'password' }, network: 'cht-agent-net', config: { @@ -40,6 +51,11 @@ export const MOCK_TEST_ENV_DATA: MockTestEnvData = { death_reporting: { disable: false }, }, forms: ['delivery', 'pregnancy', 'assessment'], + formVersions: { + delivery: '1-mockrev', + pregnancy: '1-mockrev', + assessment: '1-mockrev', + }, }, testData: { placesCreated: 3, @@ -47,5 +63,21 @@ export const MOCK_TEST_ENV_DATA: MockTestEnvData = { reportsCreated: 4, usersCreated: 2, warnings: [], + succeeded: true, + // One id per mock place/person/report (users are accounts, not docs). + seededDocIds: [ + 'mock-place-district-1', + 'mock-place-health-center-1', + 'mock-place-clinic-1', + 'mock-person-1', + 'mock-person-2', + 'mock-person-3', + 'mock-person-4', + 'mock-person-5', + 'mock-report-delivery-1', + 'mock-report-pregnancy-1', + 'mock-report-pregnancy-2', + 'mock-report-assessment-1', + ], }, }; diff --git a/src/agents/test-environment-agent.ts b/src/agents/test-environment-agent.ts index bbf0c5dd..cb6cac2b 100644 --- a/src/agents/test-environment-agent.ts +++ b/src/agents/test-environment-agent.ts @@ -4,86 +4,938 @@ * Deterministic provisioning orchestrator for the Test Environment Layer * (QA Supervisor). It provisions a live CHT instance, applies a config, * discovers the deployed config, and seeds conforming test data. No LLM is - * involved. This file implements mock mode only; the real Docker / cht-conf / - * CouchDB orchestration lands in #66. + * involved. Real paths: provision (human-gated bring-up + readiness polling), + * applyConfig (cht-conf upload buckets), discoverConfig (settings + form-rev + * fetch), prepareTestData (cht-conf csv-to-docs/upload-docs/create-users), + * and the couchdb-tier reset (tracked-doc wipe + reseed over the CouchDB HTTP + * API — the one reset the agent does itself; restart/full stay human-gated). + * The pipeline wiring that invokes this layer lands with #64. * * See: designs/layer_recommendations/test-environment-layer.md */ +import { resolve } from 'node:path'; import { + ApplyConfigOptions, + ChtConfExecOptions, + ChtConfExecResult, + ConfigActionResult, + ConfigApplyResult, + ConfigUploadAction, + ContactTypeConfig, DiscoveredConfig, EnvironmentHandle, + PrepareTestDataOptions, ProvisionOptions, + ResetOptions, + ResetResult, ResetTier, + RoleConfig, TestDataResult, + TransitionConfig, } from '../types'; -import { MOCK_TEST_ENV_DATA } from './test-environment-agent.mock-data'; +import { MOCK_TEST_ENV_DATA, mockConfigActionResult } from './test-environment-agent.mock-data'; +import { waitForReady } from '../utils/cht-readiness'; +import { FORM_BUCKETS, outputTail, runBucket, runChtConf } from '../utils/cht-conf-runner'; +import { + BulkDoc, + bulkDocs, + DocRevRow, + fetchDocRevs, + fetchFormRevs, + fetchSession, + fetchSettings, +} from '../utils/cht-api'; +import { + classifySeededDocs, + countCreatedUsers, + findForeignDocFiles, + hasCsvInput, + hasUsersCsv, + parseUploadDocsSummary, + readSeededDocs, + recordOwnedDocFiles, + removeOwnedDocFiles, + SeededDoc, + SeededDocCounts, +} from '../utils/test-data'; + +// Real-path defaults: scripts/test-env-up.sh brings CHT up on cht-agent-net with these +// same credentials and with COMMON_NAME=nginx, so the stack's SAN-less self-signed cert +// names the host the agent dials. The cht-conf child gets --accept-self-signed-certs; the +// agent's own fetch trusts the cert via NODE_EXTRA_CA_CERTS=. Node +// loads that file once, no later than the process's first TLS connection attempt (even a +// failed one). The nginx container makes a new key for each new cht-ssl volume (the first +// up, and every up after down -v). So start the agent after the cert exists, and restart it +// after a full reset. +// NODE_TLS_REJECT_UNAUTHORIZED=0 disables verification for ALL agent traffic (LLM/MCP +// included): disposable runner containers only. +const DEFAULT_ENV_URL = 'https://nginx'; +const DEFAULT_NETWORK = 'cht-agent-net'; +const DEFAULT_AUTH = { user: 'medic', password: 'password' }; +// A cold first run of test-env-up.sh (clone, npm ci, build-dev, local-images) takes +// about 18 minutes. +const DEFAULT_PROVISION_WAIT_MS = 1_800_000; + +// Default config project (cht-core in-repo) and the full cht-conf upload set. +const DEFAULT_CONFIG_PATH = 'config/default'; +const DEFAULT_CONFIG_ACTIONS: ConfigUploadAction[] = [ + 'app-settings', + 'app-forms', + 'contact-forms', + 'resources', +]; + +const CSV_TO_DOCS = 'csv-to-docs'; +const UPLOAD_DOCS = 'upload-docs'; + +// CouchDB id prefix of installed form docs (form:pregnancy -> pregnancy). +const FORM_DOC_PREFIX = 'form:'; + +/** + * Decode a URL userinfo component. Userinfo SHOULD be percent-encoded, but a + * raw '%' in a hand-typed CHT_URL password must not crash provision with an + * opaque URIError — fall back to the literal value. + */ +const decodeUserinfo = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +/** Strip trailing slashes without a backtracking regex. */ +const stripTrailingSlashes = (value: string): string => { + let result = value; + while (result.endsWith('/')) { + result = result.slice(0, -1); + } + return result; +}; + +// POSIX single-quote escaping: close the quote, emit an escaped one, reopen. +const SINGLE_QUOTE_ESCAPE = String.raw`'\''`; + +/** Single-quote a path for the printed human-gate commands (spaces/metachars stay inert when pasted). */ +const shellQuote = (value: string): string => `'${value.replaceAll("'", SINGLE_QUOTE_ESCAPE)}'`; + +/** + * Argument for a printed gate command: a quoted path, or nothing when we don't + * know one — the scripts resolve CHT_CORE_PATH (or their managed checkout) + * themselves, so a bare command is runnable while a `''` placeholder + * would not be. The human runs the gate on the host: a dockerized agent sees its + * working copy at the mount target, so CHT_CORE_HOST_PATH (the mount source) wins. + */ +const gateArg = (chtCorePath: string | undefined): string => { + const hostPath = process.env.CHT_CORE_HOST_PATH?.trim(); + // Printed into the human-gate command lines, like chtCorePath (validateProvisionOptions). + if (hostPath && hasControlChars(hostPath)) { + throw new Error('CHT_CORE_HOST_PATH contains control characters: it must be a plain filesystem path'); + } + const gatePath = hostPath || chtCorePath; + return gatePath === undefined ? '' : ` ${shellQuote(gatePath)}`; +}; + +/** Carry an explicit Compose project into the printed gates, so down/restart address the same stack. */ +const gateEnv = (): string => { + const project = process.env.CHT_TEST_ENV_PROJECT; + return project ? `CHT_TEST_ENV_PROJECT=${shellQuote(project)} ` : ''; +}; + +const hasControlChars = (value: string): boolean => + [...value].some((ch) => (ch.codePointAt(0) ?? 0) < 0x20); + +type BasicAuth = { user: string; password: string }; + +/** A resolved real-mode target: a credential-free instance URL + its auth. */ +interface RealTarget { + url: string; + auth: BasicAuth; +} + +/** Hosts treated as disposable test instances (see assertDisposableTarget). */ +const DISPOSABLE_HOSTS = new Set(['nginx', 'localhost', '127.0.0.1', '[::1]']); + +// cht-docker-helper serves a stack on a dashed IP under this resolver, but the resolver +// answers for ANY address (203-0-113-5.local-ip.medicmobile.org is public). +const PRIVATE_DASHED_IP = + /^(?:10-\d{1,3}|127-\d{1,3}|192-168|172-(?:1[6-9]|2\d|3[01]))-\d{1,3}-\d{1,3}\.local-ip\.medicmobile\.org$/; + +// `.local` is deliberately absent: it is also the legacy Active Directory suffix. +const isDisposableHost = (hostname: string): boolean => + DISPOSABLE_HOSTS.has(hostname) || hostname.endsWith('.localhost') || PRIVATE_DASHED_IP.test(hostname); + +/** Parse an instance URL without letting ERR_INVALID_URL carry the raw string (password included). */ +const parseInstanceUrl = (raw: string): URL => { + try { + return new URL(raw); + } catch { + throw new Error('provision: the instance URL (options.url / CHT_URL) is not a valid URL'); + } +}; + +/** Credentials embedded in a URL. Both halves are required: `https://medic@host` carries none. */ +const embeddedAuthOf = (url: URL): BasicAuth | undefined => + url.username && url.password + ? { user: decodeUserinfo(url.username), password: decodeUserinfo(url.password) } + : undefined; + +/** The canonical, credential-free form of an instance URL; handle.url reaches logs. */ +const withoutUserinfo = (url: URL): string => { + const copy = new URL(url); + copy.username = ''; + copy.password = ''; + return stripTrailingSlashes(copy.toString()); +}; + +/** + * Auth precedence: options.auth, then URL-embedded, then COUCHDB_USER / COUCHDB_PASSWORD, + * which default independently exactly as the scripts' `${VAR:-default}` do. `isDefault` + * means nobody supplied a password, so the guard can refuse to send the built-in one away. + */ +const resolveRealAuth = ( + options: ProvisionOptions, + embeddedAuth: BasicAuth | undefined +): { auth: BasicAuth; isDefault: boolean } => { + const supplied = options.auth ?? embeddedAuth; + if (supplied) { + return { auth: supplied, isDefault: false }; + } + const password = process.env.COUCHDB_PASSWORD || undefined; + return { + auth: { user: process.env.COUCHDB_USER || DEFAULT_AUTH.user, password: password ?? DEFAULT_AUTH.password }, + isDefault: password === undefined, + }; +}; + +/** https is always allowed; http only when the host is a local disposable one. */ +const isSchemeAllowed = (target: URL, disposable: boolean): boolean => + target.protocol === 'https:' || (target.protocol === 'http:' && disposable); + +const isExternalTargetAllowed = (options: ProvisionOptions): boolean => + options.allowExternalTarget === true || process.env.CHT_TEST_ENV_ALLOW_EXTERNAL === '1'; + +/** + * Refuse to aim the destructive paths at anything but a disposable test instance: + * applyConfig runs cht-conf with `--force` and reset('couchdb') deletes docs, so a + * stale CHT_URL pointing at staging must fail loudly rather than clobber it. + * Override per call with allowExternalTarget, or with CHT_TEST_ENV_ALLOW_EXTERNAL=1. + */ +const assertDisposableTarget = (target: URL, options: ProvisionOptions, defaultCreds: boolean): void => { + const disposable = isDisposableHost(target.hostname); + if (!isSchemeAllowed(target, disposable)) { + throw new Error( + `provision: refusing ${target.protocol}//${target.host} — https is required ` + + '(http only for a local disposable instance)' + ); + } + if (disposable) { + return; + } + if (!isExternalTargetAllowed(options)) { + throw new Error( + `provision: ${target.host} is not a known disposable test instance, and this layer runs ` + + 'cht-conf --force and deletes docs. Set allowExternalTarget (or ' + + 'CHT_TEST_ENV_ALLOW_EXTERNAL=1) if that really is the target.' + ); + } + if (defaultCreds) { + throw new Error( + `provision: refusing the built-in default credentials against ${target.host}: nobody supplied a ` + + 'password. Pass auth explicitly or set COUCHDB_PASSWORD.' + ); + } +}; + +/** + * Resolve the real-mode instance URL + credentials. URL: options.url, then CHT_URL + * (trimmed; blank ignored), then the on-network default. Embedded basic-auth creds are + * stripped out of the URL (it is logged, and undici rejects credentialed URLs) and kept + * only as an auth fallback. + */ +const resolveRealTarget = (options: ProvisionOptions): RealTarget => { + const target = parseInstanceUrl(options.url ?? (process.env.CHT_URL?.trim() || DEFAULT_ENV_URL)); + const { auth, isDefault } = resolveRealAuth(options, embeddedAuthOf(target)); + assertDisposableTarget(target, options, isDefault); + return { url: withoutUserinfo(target), auth }; +}; + +/** A docIds worklist with no dataPath to reseed from is a caller error, not an empty reset. */ +const assertNoOrphanDocIds = (options: ResetOptions): void => { + if (options.docIds !== undefined) { + throw new Error('reset: options.docIds also needs options.dataPath (the project to reseed from)'); + } +}; + +/** Reject provision inputs before anything is printed or polled. */ +const validateProvisionOptions = (options: ProvisionOptions): void => { + if (!options.chtCorePath && !options.version) { + throw new Error('provision requires either chtCorePath or version'); + } + // The path is interpolated into printed human-gate command lines. + if (options.chtCorePath && hasControlChars(options.chtCorePath)) { + throw new Error('provision: chtCorePath contains control characters — must be a plain filesystem path'); + } + // The gates single-quote the path, so a shell never expands a leading ~; cht-conf gets it literally too. + if (options.chtCorePath?.startsWith('~')) { + throw new Error('provision: chtCorePath starts with ~, which nothing expands; pass an absolute path'); + } +}; + +/** + * Real mode brings a stack up from a cht-core working copy only. A published version comes + * up through cht-docker-helper, which this layer prints no gate for, so real mode refuses it. + * Checking the release tag out in a working copy reaches the same code. + */ +const assertRealModeSupported = (options: ProvisionOptions, network: string): void => { + if (!options.chtCorePath) { + throw new Error( + `provision: real mode has no published-version bring-up — check ${options.version} out in a ` + + 'cht-core working copy and pass chtCorePath' + ); + } + if (network !== DEFAULT_NETWORK) { + throw new Error( + 'provision: the bring-up scripts and docker/cht-agent-net.override.yml hardcode ' + + `${DEFAULT_NETWORK}; a custom network needs an edit there too` + ); + } +}; + +/** + * /api/v2/monitoring needs no auth, so readiness proves nothing about the credentials. cht-conf + * --force and the reset's _bulk_docs both need a CouchDB admin: check that now, not minutes later. + */ +const assertAdminCredentials = async (url: string, auth: BasicAuth): Promise => { + const session = await fetchSession(url, auth).catch((error: Error) => { + throw new Error(`provision: could not verify the credentials for ${auth.user}: ${error.message}`); + }); + if (!session.roles.includes('_admin')) { + throw new Error( + `provision: ${auth.user} is authenticated but is not a CouchDB admin; apply and reset need one` + ); + } +}; + +/** Real provision: gate, wait for health, prove admin credentials, hand back the handle. */ +const provisionReal = async (options: ProvisionOptions, network: string): Promise => { + assertRealModeSupported(options, network); + const { url, auth } = resolveRealTarget(options); + + console.log('[Test Environment Agent] HUMAN GATE — bring the env up (agent runs no Docker):'); + console.log(` CHT_CORE_REBUILD=1 ${gateEnv()}scripts/test-env-up.sh${gateArg(options.chtCorePath)} # build + start on ${network}`); + console.log(`[Test Environment Agent] Polling ${url}/api/v2/monitoring until healthy...`); + + const readiness = { + ...options.readiness, + maxWaitMs: options.readiness?.maxWaitMs ?? DEFAULT_PROVISION_WAIT_MS, + }; + await waitForReady(url, readiness); + await assertAdminCredentials(url, auth); + + console.log(`[Test Environment Agent] Ready at ${url} (network: ${network})`); + return { url, auth: { ...auth }, network, chtCorePath: options.chtCorePath, source: 'docker' }; +}; + +/** Build the deterministic mock-mode handle (no instance, no Docker). */ +const buildMockHandle = (options: ProvisionOptions, network: string): EnvironmentHandle => { + const target = parseInstanceUrl(options.url ?? MOCK_TEST_ENV_DATA.url); + return { + url: withoutUserinfo(target), + auth: { ...(options.auth ?? embeddedAuthOf(target) ?? MOCK_TEST_ENV_DATA.auth) }, + network, + chtCorePath: options.chtCorePath, + source: 'mock', + }; +}; + +/** + * Build the cht-conf instance URL with embedded credentials + * (https://user:pass@host). Only the runner sees this; logs use handle.url. + */ +const credentialedUrl = (handle: EnvironmentHandle): string => { + const url = new URL(handle.url); + url.username = encodeURIComponent(handle.auth.user); + url.password = encodeURIComponent(handle.auth.password); + return url.toString(); +}; + +/** Default config project: cht-core's in-repo config/default, resolved against the handle's working copy. */ +const defaultConfigPath = (handle: EnvironmentHandle): string => + handle.chtCorePath + ? `${stripTrailingSlashes(handle.chtCorePath)}/${DEFAULT_CONFIG_PATH}` + : DEFAULT_CONFIG_PATH; + +/** + * Aggregate per-bucket results into the ConfigApplyResult envelope. Shared by + * the mock and real paths so both return an identical shape. + */ +const toApplyResult = ( + configPath: string, + artifact: string | undefined, + results: ConfigActionResult[] +): ConfigApplyResult => { + // An artifact filter legitimately matches nothing in the OTHER form bucket + // (targeting an app form never matches contact-forms), so a miss only means + // the request failed when EVERY form bucket came up empty. + const formResults = results.filter((result) => FORM_BUCKETS.has(result.action)); + const artifactMissed = + artifact !== undefined && + formResults.length > 0 && + formResults.every((result) => result.matchedNothing === true); + const warnings = results.flatMap((result) => result.warnings); + if (artifactMissed) { + warnings.push(`no configured form bucket contains an artifact named "${artifact}"`); + } + return { + configPath, + ...(artifact ? { artifact } : {}), + actions: results, + succeeded: results.every((result) => result.status !== 'failed') && !artifactMissed, + warnings, + }; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const parseContactTypes = (raw: unknown): ContactTypeConfig[] => { + if (!Array.isArray(raw)) { + return []; + } + return raw.filter(isRecord).flatMap((entry) => { + if (typeof entry.id !== 'string') { + return []; + } + const parents = Array.isArray(entry.parents) + ? entry.parents.filter((parent): parent is string => typeof parent === 'string') + : undefined; + return [ + { + id: entry.id, + ...(parents !== undefined ? { parents } : {}), + ...(typeof entry.person === 'boolean' ? { person: entry.person } : {}), + }, + ]; + }); +}; + +const parseRole = (value: unknown): RoleConfig | undefined => { + if (!isRecord(value)) { + return undefined; + } + return { + ...(typeof value.name === 'string' ? { name: value.name } : {}), + ...(typeof value.offline === 'boolean' ? { offline: value.offline } : {}), + }; +}; + +const parseRoles = (raw: unknown): Record => { + if (!isRecord(raw)) { + return {}; + } + const roles: Record = {}; + for (const [name, value] of Object.entries(raw)) { + const role = parseRole(value); + if (role !== undefined) { + roles[name] = role; + } + } + return roles; +}; + +const parsePermissions = (raw: unknown): Record => { + if (!isRecord(raw)) { + return {}; + } + const permissions: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (Array.isArray(value)) { + permissions[name] = value.filter((role): role is string => typeof role === 'string'); + } + } + return permissions; +}; + +const parseTransitions = (raw: unknown): Record => { + if (!isRecord(raw)) { + return {}; + } + const transitions: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (typeof value === 'boolean') { + transitions[name] = value; + } else if (isRecord(value)) { + transitions[name] = { disable: value.disable === true }; + } + } + return transitions; +}; + +/** + * Map raw /api/v1/settings JSON + the installed form docs' revs into the + * DiscoveredConfig the data/test layers consume. Pure — exported for nothing; + * exercised through discoverConfig. + */ +const parseDiscoveredConfig = ( + settings: Record, + formRevs: Array<{ id: string; rev: string }> +): DiscoveredConfig => { + const formVersions: Record = {}; + for (const form of formRevs) { + const id = form.id.startsWith(FORM_DOC_PREFIX) ? form.id.slice(FORM_DOC_PREFIX.length) : form.id; + formVersions[id] = form.rev; + } + return { + contactTypes: parseContactTypes(settings.contact_types), + roles: parseRoles(settings.roles), + permissions: parsePermissions(settings.permissions), + transitions: parseTransitions(settings.transitions), + forms: Object.keys(formVersions), + formVersions, + }; +}; + +/** One line describing why a cht-conf invocation did not succeed. */ +const describeExit = (label: string, run: ChtConfExecResult): string => { + if (run.timedOut) { + return `cht-conf ${label} timed out`; + } + if (run.startError !== undefined) { + return `cht-conf ${label} failed to start: ${run.startError}`; + } + return `cht-conf ${label} exited with code ${run.exitCode}`; +}; + +/** Why a cht-conf run did not succeed, with cht-conf's own ERROR lines when it printed any. */ +const describeRunFailure = (label: string, run: ChtConfExecResult): string => + [describeExit(label, run), ...outputTail(run.output)].join(' | '); + +const runSucceeded = (run: ChtConfExecResult): boolean => + run.exitCode === 0 && !run.timedOut && run.startError === undefined; + +// Doc ids that name deployed configuration or accounts. They are refused wherever a data +// project reaches the instance or the reset worklist. +const PROTECTED_IDS = new Set([ + 'settings', + 'resources', + 'branding', + 'partners', + 'extension-libs', + 'privacy-policies', + 'service-worker-meta', + 'zscore-charts', + 'migration-log', + 'shortcode-id-length', +]); +const PROTECTED_PREFIXES = ['_design/', 'form:', 'org.couchdb.user:', 'messages-']; + +const isProtectedId = (id: string): boolean => + PROTECTED_IDS.has(id) || PROTECTED_PREFIXES.some((prefix) => id.startsWith(prefix)); + +const partitionProtected = (ids: string[]): { safe: string[]; protectedIds: string[] } => { + const safe: string[] = []; + const protectedIds: string[] = []; + for (const id of ids) { + (isProtectedId(id) ? protectedIds : safe).push(id); + } + return { safe, protectedIds }; +}; + +/** `upload-docs --force` writes whatever json_docs holds, so a protected id is refused up front. */ +const assertNoProtectedDocs = (label: string, dataPath: string, docs: SeededDoc[]): void => { + const planted = partitionProtected(docs.map((doc) => doc.id)).protectedIds; + if (planted.length > 0) { + throw new Error( + `${label}: ${dataPath}/json_docs contains protected config doc(s) ${planted.slice(0, 5).join(', ')} — ` + + 'upload-docs would write them to the instance; remove them first' + ); + } +}; + +/** Tracking key — the URL alone collides when parallel envs share the service hostname. */ +const trackingKey = (handle: EnvironmentHandle): string => `${handle.network}|${handle.url}`; + +/** Where a seed or reseed reads from, and the cht-conf it runs with. */ +interface SeedSource { + /** Absolute, so a later chdir cannot move a reset onto another project. */ + dataPath: string; + bin?: string; + timeoutMs?: number; +} + +/** What prepareTestData tracked for a provisioned env: its source and the ids it seeded. */ +interface SeededDataRecord extends SeedSource { + docIds: string[]; +} + +/** cht-conf run options shared by the seeding phases (verbs/logLabel added per call). */ +type SeedRunBase = Pick; + +/** + * Options for every seed and reseed run. cht-conf resolves --source against the child's cwd, + * which is the data project (so its upload-docs report lands there), so both are absolute. + */ +const seedRunBase = (handle: EnvironmentHandle, source: SeedSource): SeedRunBase => ({ + instanceUrl: credentialedUrl(handle), + configPath: resolve(source.dataPath), + cwd: resolve(source.dataPath), + bin: source.bin, + timeoutMs: source.timeoutMs, +}); + +/** + * upload-docs exits 0 even when it rejects docs. A static dataset seeded twice conflicts on + * every doc, because csv-to-docs ids hash the content. + */ +const uploadShortfall = (run: ChtConfExecResult, docCount: number): string | undefined => { + const summary = parseUploadDocsSummary(run.output); + if (summary === undefined) { + return docCount > 0 ? `upload-docs printed no summary for ${docCount} doc(s)` : undefined; + } + if (summary.uploaded < summary.total) { + // reset('couchdb') wipes AND reseeds, so a second seed of the same docs after it still conflicts. + return ( + `only ${summary.uploaded} of ${summary.total} docs uploaded — the rest were rejected or already ` + + "exist. Seeding data the instance already holds conflicts; reset('couchdb') restores it instead" + ); + } + return undefined; +}; + +/** Refuse anything in json_docs this layer did not generate; remove what it did. */ +const clearOwnedDocs = (dataPath: string): void => { + const foreign = findForeignDocFiles(dataPath); + if (foreign.length > 0) { + throw new Error( + `prepareTestData: ${dataPath}/json_docs holds ${foreign.length} file(s) this layer did not generate ` + + `(${foreign.slice(0, 3).join(', ')}) — move them out or use a fresh data project` + ); + } + const removed = removeOwnedDocFiles(dataPath); + if (removed > 0) { + console.log(`[Test Environment Agent] Cleared ${removed} json_docs file(s) a previous run generated`); + } +}; + +/** csv-to-docs into a json_docs holding nothing but this layer's own earlier output. */ +const generateDocs = async (shared: SeedRunBase, dataPath: string, warnings: string[]): Promise => { + clearOwnedDocs(dataPath); + const run = await runChtConf({ verbs: [CSV_TO_DOCS], logLabel: `test-data: ${CSV_TO_DOCS}`, ...shared }); + // Everything in json_docs now came from csv-to-docs: record it even on failure, so a retry + // can clear it. + recordOwnedDocFiles(dataPath); + if (!runSucceeded(run)) { + warnings.push(describeRunFailure(CSV_TO_DOCS, run)); + } + return runSucceeded(run); +}; + +const uploadDocs = async (shared: SeedRunBase, docCount: number): Promise<{ ran: boolean; problem?: string }> => { + const run = await runChtConf({ verbs: [UPLOAD_DOCS], logLabel: `test-data: ${UPLOAD_DOCS}`, ...shared }); + if (!runSucceeded(run)) { + return { ran: false, problem: describeRunFailure(UPLOAD_DOCS, run) }; + } + return { ran: true, problem: uploadShortfall(run, docCount) }; +}; + +interface DocsPhase { + /** Every doc uploaded cleanly. */ + docsOk: boolean; + /** upload-docs exited cleanly, so what is on disk is what it was given. */ + uploadRan: boolean; + seeded: SeededDoc[]; + counts: SeededDocCounts; +} + +/** + * Docs phase. With csv/ input, json_docs is regenerated (see generateDocs); without it, + * json_docs is hand-authored input and is uploaded as it is. Either way protected ids are + * refused before upload-docs runs, which is why the two verbs are separate cht-conf calls. + */ +const prepareDocs = async ( + shared: SeedRunBase, + dataPath: string, + config: DiscoveredConfig, + warnings: string[] +): Promise => { + if (hasCsvInput(dataPath) && !(await generateDocs(shared, dataPath, warnings))) { + return { docsOk: false, uploadRan: false, seeded: [], counts: classifySeededDocs([], config) }; + } + const seeded = readSeededDocs(dataPath); + assertNoProtectedDocs('prepareTestData', dataPath, seeded); + const upload = await uploadDocs(shared, seeded.length); + if (upload.problem !== undefined) { + warnings.push(upload.problem); + } + if (seeded.length === 0) { + warnings.push(`no docs in ${dataPath}/json_docs — does ${dataPath}/csv exist and contain CSV files?`); + } + const counts = classifySeededDocs(seeded, config); + warnings.push(...counts.warnings); + return { docsOk: upload.problem === undefined, uploadRan: upload.ran, seeded, counts }; +}; -const NOT_IMPLEMENTED = 'Docker orchestration not yet implemented'; +/** + * Users phase: cht-conf `create-users` from `/users.csv` when present + * (cht-conf throws on a missing users.csv). On a failed run the last logged + * "Creating user" attempt is the one that blew up, so it is not counted. + */ +const seedUsers = async ( + shared: SeedRunBase, + dataPath: string, + warnings: string[] +): Promise<{ usersCreated: number; usersOk: boolean }> => { + if (!hasUsersCsv(dataPath)) { + console.log('[Test Environment Agent] No users.csv in the data project — skipping create-users'); + return { usersCreated: 0, usersOk: true }; + } + const usersRun = await runChtConf({ verbs: ['create-users'], logLabel: 'test-data: create-users', ...shared }); + const usersOk = runSucceeded(usersRun); + const attempts = countCreatedUsers(usersRun.output); + const usersCreated = usersOk ? attempts : Math.max(0, attempts - 1); + if (!usersOk) { + warnings.push(describeRunFailure('create-users', usersRun)); + } + return { usersCreated, usersOk }; +}; + +/** Build _deleted tombstones for the live (non-tombstoned, non-missing) tracked docs. */ +const buildTombstones = (rows: DocRevRow[]): BulkDoc[] => { + const deletions: BulkDoc[] = []; + for (const row of rows) { + if (row.rev !== undefined && !row.deleted && !row.missing) { + deletions.push({ _id: row.id, _rev: row.rev, _deleted: true }); + } + } + return deletions; +}; + +/** Never wipe a doc the data project cannot put back. */ +const assertRestorable = (dataPath: string, ids: string[], onDisk: SeededDoc[]): void => { + const available = new Set(onDisk.map((doc) => doc.id)); + const unrestorable = ids.filter((id) => !available.has(id)); + if (unrestorable.length > 0) { + throw new Error( + `couchdb reset: ${dataPath}/json_docs cannot restore ${unrestorable.length} of the ${ids.length} ` + + `doc(s) it would wipe (${unrestorable.slice(0, 5).join(', ')}) — re-run prepareTestData instead` + ); + } +}; + +/** + * couchdb reset — wipe: delete the tracked docs at their CURRENT revs (sentinel may have + * bumped them). Throws unless CouchDB acknowledged every submitted deletion with `ok`. + * Returns how many were tombstoned. + */ +const wipeTrackedDocs = async (handle: EnvironmentHandle, docIds: string[]): Promise => { + const rows = await fetchDocRevs(handle.url, handle.auth, docIds); + const deletions = buildTombstones(rows); + if (deletions.length === 0) { + return 0; + } + const outcomes = await bulkDocs(handle.url, handle.auth, deletions); + if (outcomes.length !== deletions.length) { + throw new Error( + `couchdb reset: _bulk_docs acknowledged ${outcomes.length} of ${deletions.length} deletion(s)` + ); + } + const failed = outcomes.filter((row) => row.error !== undefined || row.ok !== true); + if (failed.length > 0) { + const failedIds = failed.map((row) => row.id ?? 'unknown').slice(0, 5).join(', '); + // _bulk_docs is not atomic: the other deletions were applied, and nothing reseeds them. + throw new Error( + `couchdb reset failed to delete ${failed.length} doc(s): ${failedIds}. The other ` + + `${deletions.length - failed.length} doc(s) were deleted and not reseeded: run the reset again` + ); + } + return deletions.length; +}; + +/** couchdb reset — reseed: re-upload the data project's pristine copies. */ +const reseedTrackedDocs = async ( + handle: EnvironmentHandle, + tracked: SeededDataRecord, + wiped: number +): Promise => { + const reseed = await runChtConf({ + verbs: [UPLOAD_DOCS], + logLabel: `couchdb reset: ${UPLOAD_DOCS}`, + ...seedRunBase(handle, tracked), + }); + if (!runSucceeded(reseed)) { + throw new Error( + `couchdb reset: ${wiped} doc(s) were wiped but the reseed failed, so they are gone from the ` + + `instance until you re-run prepareTestData — ${describeRunFailure(UPLOAD_DOCS, reseed)}` + ); + } +}; + +/** + * Confirm from CouchDB that every doc the reset is responsible for exists again. upload-docs' + * summary cannot say: it re-uploads all of json_docs with no _rev, mixing restored docs with + * conflicts on docs that were never wiped. + */ +const verifyRestored = async (handle: EnvironmentHandle, ids: string[]): Promise => { + const rows = await fetchDocRevs(handle.url, handle.auth, ids); + const present = new Set( + rows.filter((row) => row.rev !== undefined && !row.deleted && !row.missing).map((row) => row.id) + ); + const absent = ids.filter((id) => !present.has(id)); + if (absent.length > 0) { + throw new Error( + `couchdb reset: ${absent.length} doc(s) are missing after the reseed: ${absent.slice(0, 5).join(', ')}` + ); + } +}; + +/** Print the human-gated restart/full reset instructions (the agent runs no Docker). */ +const printResetGate = (handle: EnvironmentHandle, tier: ResetTier): void => { + const target = gateArg(handle.chtCorePath); + const env = gateEnv(); + console.log(`[Test Environment Agent] HUMAN GATE — reset (${tier}); the agent runs no Docker:`); + if (tier === 'restart') { + console.log(` ${env}scripts/test-env-restart.sh${target}`); + } else { + // A full reset recreates the containers from rebuilt images (Model A tests the code as it is now). + // It is not a clean database: the CouchDB bind mount survives down -v (see reset()). + console.log(` ${env}scripts/test-env-down.sh${target} && ${env}CHT_CORE_REBUILD=1 scripts/test-env-up.sh${target}`); + // down -v deletes the cht-ssl volume, so nginx comes back with a new self-signed cert. + // Node loads NODE_EXTRA_CA_CERTS once per process, so this process still trusts the old one. + console.log( + '[Test Environment Agent] down -v deletes the nginx cert, and up makes a new one. If NODE_EXTRA_CA_CERTS ' + + 'trusts the old one, copy the new cert.pem and restart the agent before re-confirming health.' + ); + } + console.log( + '[Test Environment Agent] The old stack answers healthy until the operator acts: get their go-ahead, ' + + 'then re-confirm health with provision()/waitForReady.' + ); +}; export class TestEnvironmentAgent { private readonly useMockDocker: boolean; + /** Seeded-doc tracking per environment (see trackingKey) for the couchdb reset. */ + private readonly seededData = new Map(); constructor(options: { useMockDocker?: boolean } = {}) { this.useMockDocker = options.useMockDocker !== false; } /** - * Bring up a reachable CHT environment. Requires either a local working copy - * (chtCorePath, built via local-images) or a published version. + * Bring up a reachable CHT environment. Real mode needs a working copy (chtCorePath, built + * by scripts/test-env-up.sh); mock mode also takes a published version. */ async provision(options: ProvisionOptions): Promise { - if (!options.chtCorePath && !options.version) { - throw new Error('provision requires either chtCorePath or version'); - } - - if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); - } + validateProvisionOptions(options); const source = options.chtCorePath ? `local code (${options.chtCorePath})` : `published version ${options.version}`; + const network = options.network ?? DEFAULT_NETWORK; console.log('\n[Test Environment Agent] Provisioning environment...'); console.log(`[Test Environment Agent] Source: ${source}`); - const handle: EnvironmentHandle = { - url: MOCK_TEST_ENV_DATA.url, - auth: { ...MOCK_TEST_ENV_DATA.auth }, - network: options.network ?? MOCK_TEST_ENV_DATA.network, - chtCorePath: options.chtCorePath, - source: 'mock', - }; + if (!this.useMockDocker) { + return provisionReal(options, network); + } + const handle = buildMockHandle(options, network); console.log(`[Test Environment Agent] Ready at ${handle.url} (network: ${handle.network})`); return handle; } /** - * Apply (compile + upload) a config project from the working copy to the - * instance. Defaults to cht-core's in-repo `config/default` project. + * Apply (compile + upload) a config project to the instance via cht-conf. + * Defaults to cht-core's in-repo `config/default`; cht-conf tickets pass the + * deployment's config project as `configPath`. `actions` picks which cht-conf + * upload buckets run — settings, app forms, contact forms, resources — so the + * cht-conf validate loop can re-upload only the artifact it changed. + * + * Accepts a bare path string (back-compat) or an options object. Returns a + * ConfigApplyResult the verify step / QA Supervisor asserts on. */ - async applyConfig(handle: EnvironmentHandle, configPath = 'config/default'): Promise { + async applyConfig( + handle: EnvironmentHandle, + options: string | ApplyConfigOptions = {} + ): Promise { + const opts: ApplyConfigOptions = typeof options === 'string' ? { configPath: options } : options; + const configPath = opts.configPath ?? defaultConfigPath(handle); + const actions = opts.actions ?? DEFAULT_CONFIG_ACTIONS; + const artifact = opts.artifact; + + const scope = artifact ? `${actions.join(', ')}; artifact=${artifact}` : actions.join(', '); + console.log(`[Test Environment Agent] Applying config: ${configPath} (${scope}) -> ${handle.url}`); + if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + const results = await this.applyConfigReal(handle, configPath, actions, opts); + return toApplyResult(configPath, artifact, results); } - console.log(`[Test Environment Agent] Applying config: ${configPath} -> ${handle.url}`); - console.log('[Test Environment Agent] (mock) config applied'); + const results = actions.map((action) => mockConfigActionResult(action)); + console.log(`[Test Environment Agent] (mock) config applied — ${results.length} action(s)`); + return toApplyResult(configPath, artifact, results); + } + + /** + * Real applyConfig path: one cht-conf invocation per bucket against the + * running instance (the agent runs no Docker — cht-conf talks over HTTP). + * Buckets run independently so one failure doesn't abort the rest; never push. + */ + private async applyConfigReal( + handle: EnvironmentHandle, + configPath: string, + actions: ConfigUploadAction[], + opts: ApplyConfigOptions + ): Promise { + const instanceUrl = credentialedUrl(handle); + // Keep cht-conf's report files in the config project (as the seeding path does). + const cwd = configPath.startsWith('/') ? configPath : undefined; + const results: ConfigActionResult[] = []; + for (const action of actions) { + results.push( + await runBucket({ + action, + instanceUrl, + configPath, + artifact: opts.artifact, + cwd, + bin: opts.bin, + timeoutMs: opts.timeoutMs, + }) + ); + } + return results; } /** * Read the deployed configuration back from the running instance so test data - * can be generated to conform to it. + * can be generated to conform to it. Doubles as the post-applyConfig verify + * primitive: formVersions carries each installed form's CouchDB rev, which + * changes iff the form was re-uploaded. */ async discoverConfig(handle: EnvironmentHandle): Promise { - if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + console.log(`[Test Environment Agent] Discovering config from ${handle.url}...`); + + let config: DiscoveredConfig; + if (this.useMockDocker) { + config = structuredClone(MOCK_TEST_ENV_DATA.config); + } else { + const settings = await fetchSettings(handle.url, handle.auth); + if (!Array.isArray(settings.contact_types)) { + // Discovery reflects only what the instance returns (like the other + // parsers), so surface that cht-core is running on its built-in + // default hierarchy rather than synthesizing types the API never sent. + console.warn( + '[Test Environment Agent] Instance settings define no contact_types — cht-core falls back to ' + + 'its built-in default hierarchy; seeded default-hierarchy places will be counted as unknown types.' + ); + } + config = parseDiscoveredConfig(settings, await fetchFormRevs(handle.url, handle.auth)); } - console.log(`[Test Environment Agent] Discovering config from ${handle.url}...`); - const config = structuredClone(MOCK_TEST_ENV_DATA.config); console.log( `[Test Environment Agent] Discovered ${config.contactTypes.length} contact types, ` + `${Object.keys(config.roles).length} roles, ${config.forms.length} forms` @@ -92,21 +944,26 @@ export class TestEnvironmentAgent { } /** - * Generate and seed test data (places, people, reports, users) that conforms - * to the discovered config. + * Seed test data (places, people, reports, users) that conforms to the + * discovered config. Real path: cht-conf `csv-to-docs` + `upload-docs` turn + * `/csv/*.csv` (or a hand-authored json_docs) into docs, then `create-users` + * provisions accounts from `/users.csv` when present. The seeded + * doc ids are tracked per environment so `reset('couchdb')` can wipe and + * reseed them without touching the deployed config. */ async prepareTestData( handle: EnvironmentHandle, - config: DiscoveredConfig + config: DiscoveredConfig, + options: PrepareTestDataOptions = {} ): Promise { - if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); - } - console.log( `[Test Environment Agent] Preparing test data for ${config.contactTypes.length} contact types -> ${handle.url}` ); - const result = structuredClone(MOCK_TEST_ENV_DATA.testData); + + const result = this.useMockDocker + ? structuredClone(MOCK_TEST_ENV_DATA.testData) + : await this.prepareTestDataReal(handle, config, options); + console.log( `[Test Environment Agent] Seeded ${result.placesCreated} places, ` + `${result.peopleCreated} people, ${result.reportsCreated} reports, ` + @@ -115,25 +972,153 @@ export class TestEnvironmentAgent { return result; } + /** + * Real prepareTestData path: seed docs (csv-to-docs + upload-docs) then users + * (create-users when users.csv exists), tracking the seeded doc ids per env for + * the couchdb reset. Requires options.dataPath (a cht-conf project with csv/ or json_docs/). + */ + private async prepareTestDataReal( + handle: EnvironmentHandle, + config: DiscoveredConfig, + options: PrepareTestDataOptions + ): Promise { + if (!options.dataPath) { + throw new Error('prepareTestData requires options.dataPath (a cht-conf project folder with csv/ or json_docs/)'); + } + const source: SeedSource = { + dataPath: resolve(options.dataPath), + bin: options.bin, + timeoutMs: options.timeoutMs, + }; + const shared = seedRunBase(handle, source); + const warnings: string[] = []; + + const { docsOk, uploadRan, seeded, counts } = await prepareDocs(shared, source.dataPath, config, warnings); + const { usersCreated, usersOk } = await seedUsers(shared, source.dataPath, warnings); + + // Only a clean upload run defines the reset worklist: a failed re-seed must not clobber a + // live one (docs from the earlier seed are still on the instance). + if (uploadRan && seeded.length > 0) { + this.seededData.set(trackingKey(handle), { ...source, docIds: seeded.map((doc) => doc.id) }); + } + + return { + placesCreated: counts.places, + peopleCreated: counts.people, + reportsCreated: counts.reports, + usersCreated, + warnings, + succeeded: docsOk && usersOk, + seededDocIds: seeded.map((doc) => doc.id), + }; + } + /** * Reset the environment to a known state. See the three-tier reset strategy - * in the recommendation doc. + * in the recommendation doc. The couchdb tier is the one reset the agent + * performs itself (CouchDB HTTP API — no Docker): it wipes the docs the last + * prepareTestData seeded, re-uploads pristine copies, and checks they are back. + * restart/full stay human-gated: they print the command and return + * `performedBy: 'human-gate'` with zero counts WITHOUT waiting for the human. The old stack + * keeps answering healthy until the operator acts, so health alone cannot show the reset + * ran. Get the operator's approval first, then re-confirm health. CouchDB data is a bind + * mount (COUCHDB_DATA), so it survives `down -v`; only the named volumes go. */ - async reset(handle: EnvironmentHandle, tier: ResetTier): Promise { - if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + async reset( + handle: EnvironmentHandle, + tier: ResetTier, + options: ResetOptions = {} + ): Promise { + const gated = (performedBy: ResetResult['performedBy']): ResetResult => ({ + tier, + wiped: 0, + reseeded: 0, + performedBy, + protectedSkipped: [], + }); + + if (this.useMockDocker) { + console.log(`[Test Environment Agent] Reset (${tier}) -> ${handle.url}`); + console.log('[Test Environment Agent] (mock) reset complete'); + return gated(tier === 'couchdb' ? 'agent' : 'human-gate'); } - console.log(`[Test Environment Agent] Reset (${tier}) -> ${handle.url}`); - console.log('[Test Environment Agent] (mock) reset complete'); + if (tier === 'couchdb') { + return this.resetCouchdbTier(handle, options); + } + printResetGate(handle, tier); + return gated('human-gate'); + } + + /** + * Reset worklist: explicit options win (they let a handle reloaded in another + * process drive the reset — the agent's tracking is in-memory), otherwise fall + * back to what prepareTestData recorded for this environment. + */ + private resolveResetWorklist( + handle: EnvironmentHandle, + options: ResetOptions + ): SeededDataRecord | undefined { + const tracked = this.seededData.get(trackingKey(handle)); + const dataPath = options.dataPath === undefined ? tracked?.dataPath : resolve(options.dataPath); + if (dataPath === undefined) { + assertNoOrphanDocIds(options); + return undefined; + } + const docIds = options.docIds ?? tracked?.docIds ?? []; + if (docIds.length === 0) { + return undefined; + } + return { ...tracked, dataPath, docIds }; + } + + /** + * couchdb-tier reset. Before anything is deleted it refuses a data project that names + * deployed config and any doc json_docs cannot put back; then it wipes, reseeds, and + * confirms from CouchDB that every wiped doc returned. A half-reset never passes as clean. + */ + private async resetCouchdbTier(handle: EnvironmentHandle, options: ResetOptions): Promise { + const tracked = this.resolveResetWorklist(handle, options); + if (tracked === undefined) { + console.log( + `[Test Environment Agent] couchdb reset: no seeded docs tracked for ${handle.url} — ` + + 'nothing to wipe (seed with prepareTestData, or pass docIds + dataPath)' + ); + return { tier: 'couchdb', wiped: 0, reseeded: 0, performedBy: 'agent', protectedSkipped: [] }; + } + const onDisk = readSeededDocs(tracked.dataPath); + assertNoProtectedDocs('couchdb reset', tracked.dataPath, onDisk); + // Tracking never holds a protected id, but a caller-supplied docIds list might. + const { safe, protectedIds } = partitionProtected(tracked.docIds); + assertRestorable(tracked.dataPath, safe, onDisk); + + console.log(`[Test Environment Agent] couchdb reset: wiping ${safe.length} seeded doc(s) -> ${handle.url}`); + const wiped = await wipeTrackedDocs(handle, safe); + await reseedTrackedDocs(handle, tracked, wiped); + await verifyRestored(handle, safe); + + // The dataset may have changed since the wiped set was seeded. + this.seededData.set(trackingKey(handle), { ...tracked, docIds: onDisk.map((doc) => doc.id) }); + console.log(`[Test Environment Agent] couchdb reset complete — ${wiped} doc(s) wiped, ${safe.length} restored`); + return { + tier: 'couchdb', + wiped, + reseeded: safe.length, + performedBy: 'agent', + protectedSkipped: protectedIds, + }; } /** - * Tear the environment down and clean up volumes. + * Tear the environment down. Tracking is kept: CouchDB data is a bind mount that survives + * `down -v`, so the seeded docs are still there when the same stack comes back, and a stale + * entry is harmless (the wipe skips docs that are gone, the reseed recreates them). */ async teardown(handle: EnvironmentHandle): Promise { if (!this.useMockDocker) { - throw new Error(NOT_IMPLEMENTED); + console.log('[Test Environment Agent] HUMAN GATE — teardown (the agent runs no Docker):'); + console.log(` ${gateEnv()}scripts/test-env-down.sh${gateArg(handle.chtCorePath)} # docker compose down -v`); + return; } console.log(`[Test Environment Agent] Teardown -> ${handle.url}`); diff --git a/src/types/index.ts b/src/types/index.ts index 48e903d2..8fbc3c7f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -772,15 +772,231 @@ export interface DiscoveredConfig { permissions: Record; transitions: Record; forms: string[]; + /** + * Installed form id -> CouchDB revision of its `form:` doc. The rev is + * the change-detection hash for the apply -> verify loop: re-discover after + * applyConfig and a changed rev proves the upload took (an unchanged rev + * matches cht-conf's `skipped` status). Populated by the real discovery + * path; optional so hand-built configs (tests, fixtures) stay lightweight. + */ + formVersions?: Record; +} + +/** + * A bucket of cht-conf upload work, mapping to the underlying cht-conf verbs. + * The agent drives these + * over HTTP against the instance; it never edits the config itself. + * - `app-settings` compile-app-settings + upload-app-settings + * - `app-settings-only` upload-app-settings (NO compile) — for a deployment- + * recovered, pre-compiled app_settings.json where + * recompiling would clobber the minified contact-summary / + * tasks / targets + * - `app-forms` convert-app-forms + upload-app-forms + * - `contact-forms` convert-contact-forms + upload-contact-forms + * - `resources` upload-resources + upload-branding + upload-custom-translations + */ +export type ConfigUploadAction = + | 'app-settings' + | 'app-settings-only' + | 'app-forms' + | 'contact-forms' + | 'resources'; + +/** + * Inputs to applyConfig. `configPath` defaults to cht-core's in-repo + * `config/default`, resolved against the handle's chtCorePath; for cht-conf + * tickets, pass the deployment's config project. + * `actions` narrows which cht-conf uploads run — omit to run the standard set + * (the cht-core default-config flow). + */ +export interface ApplyConfigOptions { + /** cht-conf project to compile + upload (default: config/default under the handle's chtCorePath). */ + configPath?: string; + /** Which cht-conf upload buckets to run (default: the four standard buckets; app-settings-only is opt-in). */ + actions?: ConfigUploadAction[]; + /** + * Restrict an upload to a single artifact by name (e.g. a form id like + * `pregnancy`) so the validate loop re-uploads only the one form it changed. + * Passed as a positional form filter to the form-upload verbs (cht-conf's + * args-form-filter); ignored with a warning for the settings/resources + * buckets. Omit to upload the whole bucket. + */ + artifact?: string; + /** Override the cht-conf binary (default: `cht`); lets the apply run a deployment-pinned cht-conf. */ + bin?: string; + /** Per-bucket timeout in ms (convert + validate + upload of a full form set can exceed the default). */ + timeoutMs?: number; +} + +/** + * Per-action outcome of a cht-conf upload. `uploaded` = the artifact changed and + * was pushed; `skipped` = cht-conf's hash check found it identical to the + * instance (no-op, not a failure); `failed` = the verb errored. The verify step + * needs this three-way distinction — a boolean can't tell "skipped" from "ran". + */ +export type ConfigActionStatus = 'uploaded' | 'skipped' | 'failed'; + +/** + * Outcome of a single cht-conf upload bucket within applyConfig. + */ +export interface ConfigActionResult { + action: ConfigUploadAction; + status: ConfigActionStatus; + /** The cht-conf verbs this bucket ran (for evidence/logging). */ + commands: string[]; + warnings: string[]; + /** + * True when cht-conf found nothing to act on (an artifact filter that matched + * no file, or a missing bucket input). Distinct from a hash-based skip, which + * means the artifact WAS found and was already current. applyConfig uses this + * to decide whether an artifact-targeted apply actually happened. + */ + matchedNothing?: boolean; +} + +/** + * Result of applying a config to the instance. The verify step (and the QA + * Supervisor) asserts on this rather than re-deriving success from logs. + */ +export interface ConfigApplyResult { + configPath: string; + /** Single artifact targeted, if the apply was narrowed to one. */ + artifact?: string; + /** Per-bucket outcome, in the order the buckets ran. */ + actions: ConfigActionResult[]; + /** True unless some action failed (a skipped/no-change action is not a failure). */ + succeeded: boolean; + warnings: string[]; +} + +/** + * Inputs to a single cht-conf bucket invocation (see src/utils/cht-conf-runner.ts). + * The runner builds the `cht` argv from these; the agent never embeds credentials + * in a log line. + */ +export interface ChtConfRunOptions { + action: ConfigUploadAction; + /** The instance URL WITH embedded credentials (https://user:pass@host). */ + instanceUrl: string; + /** Project folder passed to cht-conf `--source`. */ + configPath: string; + /** Optional single-form filter (positional arg on the form-upload verbs). */ + artifact?: string; + /** Working directory for the spawned process (see ChtConfExecOptions.cwd). */ + cwd?: string; + /** Override the cht-conf binary (default: `cht`); lets tests stub a fake script. */ + bin?: string; + /** Per-bucket timeout in ms before the process is killed and marked failed. */ + timeoutMs?: number; +} + +/** + * Inputs to a generic cht-conf invocation (the low-level runner under + * runBucket, also driving the test-data verbs csv-to-docs / upload-docs / + * create-users). Verbs run in order inside ONE `cht` process. + */ +export interface ChtConfExecOptions { + /** cht-conf actions to run, in order (e.g. ['csv-to-docs', 'upload-docs']). */ + verbs: string[]; + /** The instance URL WITH embedded credentials (https://user:pass@host). */ + instanceUrl: string; + /** Project folder passed to cht-conf `--source`. */ + configPath: string; + /** Positional args appended after the verbs (e.g. a form filter). */ + extraArgs?: string[]; + /** + * Working directory for the spawned process. cht-conf writes report files + * (upload-docs..log.json) into its cwd, so data runs point this at the + * data project to keep droppings out of the repo. Defaults to the agent cwd. + */ + cwd?: string; + /** Log label ("[cht-conf]