diff --git a/CHANGELOG.md b/CHANGELOG.md index 3081ade..0f3ac6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.34.63] - 2026-10-23 + +### Added +- **`status`, `stop`, `start` and `uninstall` for an installed Endpoint.** The installer could only install: stopping an Endpoint meant guessing its Compose project and port, removing one was a hunt through containers, volumes, the CKAN it set up and the `.env.backup.*` files that hold old credentials, and support could not ask what was running without reading `.env`. The same script now takes a command, from the checkout or from the web (where it acts on the installed checkout without fetching or moving it), and finds everything by the Compose project's labels. `status` reports instance, project, installer version, catalog, containers, port, `/health`, NetBird and the CKAN set up for it, and changes nothing. `stop` and `start` stop and start the Endpoint's containers and its CKAN as they were created, so the port stays (a bare `docker compose up` publishes the default port, #370). `uninstall` asks first (`--yes` without a terminal), removes the containers, networks, the CKAN containers the installer created and the `.env.backup.*` files, keeps the data, `.env` and install state so a later install brings it back with its data, and with `--remove-data` deletes the volumes, `.env` and the install state too; it never deletes the checkout and leaves NetBird, saying how to leave it. The install state now records `EP_API_PORT`, `CKAN_DIR` and `CKAN_CREATED`. New tests in `install/tests/test_lifecycle_commands.py`. Verified on this machine: an Endpoint with MongoDB and MinIO installed, reported by `status`, stopped (port no longer answering) and started (healthy on the same port, its bucket still there), uninstalled keeping its volumes and reinstalled with its data, then uninstalled with `--remove-data` leaving no container, volume or credential file; `status` from the web reported two Endpoints installed with 0.34.56 on the same host, each with its own port and health (#372). + ## [0.34.62] - 2026-10-23 ### Security diff --git a/api/config/swagger_settings.py b/api/config/swagger_settings.py index cddc1b0..5e6c02c 100644 --- a/api/config/swagger_settings.py +++ b/api/config/swagger_settings.py @@ -12,7 +12,7 @@ class Settings(BaseSettings): swagger_title: str = "API Documentation" swagger_description: str = "This is the API documentation." - swagger_version: str = "0.34.62" + swagger_version: str = "0.34.63" root_path: str = "" # API root path prefix (e.g., "/test" or "") is_public: bool = True metrics_endpoint: str = "https://federation.ndp.utah.edu/metrics/" diff --git a/docs/operations.md b/docs/operations.md index 3e3d659..b70e636 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -16,6 +16,10 @@ The installer renders `.env` from `example.env`, then runs `docker compose --profile <...> up -d --build` from the repository checkout and waits for `${ROOT_PATH}/health` to answer 200. +Once installed, `./install/install.sh status`, `stop`, `start` and +`uninstall [--remove-data]` manage it (see +[install/README.md](../install/README.md#managing-an-installed-endpoint)). + ## What runs Everything is defined in `docker-compose.yml`. The `api` service always starts; diff --git a/install/README.md b/install/README.md index 55c52cc..a629d5a 100644 --- a/install/README.md +++ b/install/README.md @@ -326,6 +326,34 @@ and prove nothing. Checking the key needs a username, so with `--ckan-url` pass `--ckan-sysadmin ` to enable it; without it the installer says the key went unverified rather than implying it passed. +## Managing an installed Endpoint + +The same script reports on, stops, starts and removes the Endpoint a checkout +holds — from the checkout (`./install/install.sh `) or from the web +(`bash <(curl …/install.sh) `, which acts on `~/ndp-ep`, or +`~/ndp-ep-` with `--instance `, or `EP_INSTALL_DIR`, without +fetching anything). It finds the containers, volumes and networks by their +Compose project label, so no port or profile has to be remembered. + +| Command | | +|---|---| +| `status` | instance, Compose project, installer version, catalog, containers running, port, `/health`, NetBird connection, the CKAN set up for it; changes nothing and writes no log | +| `stop` | stops the Endpoint's containers, and the CKAN the installer set up for it | +| `start` | starts them again as they were created, on the same ports (unlike a bare `docker compose up`, which publishes the default port, #370) | +| `uninstall` | removes the containers and networks, the CKAN containers the installer created, and the `.env.backup.*` files, which hold old credentials; asks first (`--yes` where nobody can answer) | +| `uninstall --remove-data` | also deletes the data volumes (and the CKAN's), `.env` and `.env.install-state` | + +Without `--remove-data` the data, `.env` and the install state stay, so running +the installer again brings the same Endpoint back with its data: the state +holds the generated MongoDB and MinIO credentials that data opens with. The +checkout itself is never deleted, and the machine stays in NetBird (other +Endpoints on it may use it); `uninstall` says how to leave. + +The install records the port (`EP_API_PORT`) and the CKAN folder it created +(`CKAN_DIR`, `CKAN_CREATED`) in `.env.install-state` since 0.34.63; for an +Endpoint installed earlier the port is read from its running container and the +CKAN folder is the installer's default. + ## The install log Every run is appended to `install.log` in the checkout (`~/ndp-ep` or diff --git a/install/install.sh b/install/install.sh index d56dcdf..0c850c4 100755 --- a/install/install.sh +++ b/install/install.sh @@ -39,6 +39,29 @@ if [[ "$_script" != /dev/fd/* && "$_script" != /proc/self/fd/* && -f "$_script" _here="$(cd "$(dirname "$_script")/.." 2>/dev/null && pwd || true)" fi +# status, stop, start and uninstall act on an Endpoint already installed +# (issue #372). Piped from the web they run against its checkout as it is: +# nothing is fetched, and the checkout is not moved to another version. +case "${1:-}" in + status|stop|start|uninstall) + if [[ -z "$_here" || ! -f "$_here/example.env" ]]; then + _instance="" + _args=("$@") + for ((_i = 0; _i < ${#_args[@]}; _i++)); do + [[ "${_args[$_i]}" == "--instance" ]] && _instance="${_args[$((_i + 1))]:-}" + done + if [[ -n "$_instance" && "$_instance" != "ndp" ]]; then + _here="${EP_INSTALL_DIR:-$HOME/ndp-ep-$_instance}" + else + _here="${EP_INSTALL_DIR:-$HOME/ndp-ep}" + fi + if [[ ! -f "$_here/example.env" ]]; then + echo "No Endpoint installed at $_here (set EP_INSTALL_DIR, or --instance )." >&2 + exit 1 + fi + fi ;; +esac + if [[ -z "$_here" || ! -f "$_here/example.env" ]]; then echo "==> Fetching the NDP Endpoint repository" command -v git >/dev/null 2>&1 || { echo "git is required to install. Please install git and retry." >&2; exit 1; } @@ -182,6 +205,9 @@ log_active="" # How long one "docker compose up --build" may take before the run gives up # (issue #376): minutes, or seconds with an "s" suffix; 0 means no limit. compose_timeout="30" +# status | stop | start | uninstall (issue #372); empty means install. +subcommand="" +remove_data="false" RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m' BLUE=$'\033[0;34m'; NC=$'\033[0m' @@ -230,6 +256,24 @@ Usage: install.sh --config-id [options] install.sh --backend mongodb [options] # standalone, no Federation + # An installed Endpoint (from its checkout, or from the web as above): + install.sh status | stop | start [--instance ] + install.sh uninstall [--remove-data] [--yes] [--instance ] + +Commands: + status What is installed and running: instance, Compose + project, version, catalog, containers, port, health, + NetBird. Changes nothing. + stop, start Stop or start this Endpoint's containers (and the + CKAN the installer set up for it) as they are, on + the ports they were created with. + uninstall Remove this Endpoint's containers and networks and + the .env backups (old credentials). Its data, .env + and install state are kept unless --remove-data, + which also deletes its volumes. Asks first; --yes + when nobody can answer. The checkout and NetBird + are left in place. + Options: --config-id Federation configuration id for this Endpoint. --config_id is accepted too: it is what the @@ -324,6 +368,10 @@ USAGE # As given, for the install log; the secrets among them are masked there. cli_args=("$@") +case "${1:-}" in + status|stop|start|uninstall) subcommand="$1"; shift ;; +esac + while [[ $# -gt 0 ]]; do case "$1" in # The underscore spellings are what the platform's "create endpoint" @@ -371,6 +419,7 @@ while [[ $# -gt 0 ]]; do # has already acted on it, and a checkout runs what it has. --ref) shift 2 ;; --compose-timeout) compose_timeout="${2-}"; shift 2 ;; + --remove-data) remove_data="true"; shift ;; -h|--help) usage; exit 0 ;; *) fail "Unknown option: $1 (try --help)" ;; esac @@ -398,6 +447,8 @@ esac # that followed are both kept. The terminal sees exactly what it did before. finish_log() { :; } [[ -n "$log_file" ]] || log_file="$REPO_ROOT/install.log" +# status only looks, so it leaves nothing behind (issue #372). +[[ "$subcommand" == "status" ]] && log_file="/dev/null" if [[ "$log_file" != "/dev/null" ]]; then if ( umask 077; : >> "$log_file" ) 2>/dev/null; then chmod 600 "$log_file" 2>/dev/null || true @@ -446,6 +497,217 @@ if [[ "$log_file" != "/dev/null" ]]; then fi fi +# -------------------------------------------------------------- +# status, stop, start, uninstall (issue #372) +# -------------------------------------------------------------- +# They act on the Endpoint this checkout holds, through the labels Compose puts +# on its containers, volumes and networks, so neither its port nor its profiles +# have to be remembered. The install state says what the installer set up. +lc_state() { + [[ -f "$REPO_ROOT/.env.install-state" ]] || return 0 + grep -E "^$1=" "$REPO_ROOT/.env.install-state" | tail -n1 | cut -d= -f2- || true +} + +lc_env() { + [[ -f "$REPO_ROOT/.env" ]] || return 0 + grep -E "^$1=" "$REPO_ROOT/.env" | tail -n1 | cut -d= -f2- | sed 's/^"\(.*\)"$/\1/' || true +} + +lc_project() { + local project + project="$(lc_env COMPOSE_PROJECT_NAME)" + if [[ -z "$project" && "$lc_instance" != "ndp" ]]; then + project="ndp-ep-$lc_instance" + fi + # Compose's own default: the checkout's directory name. + [[ -n "$project" ]] || project="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')" + printf '%s' "$project" +} + +# The CKAN this installer set up for the Endpoint, if any: recorded since +# 0.34.63, otherwise where the installer puts it by default. +lc_ckan_dir() { + local dir + dir="$(lc_state CKAN_DIR)" + if [[ -z "$dir" && -n "$(lc_state CKAN_URL)" ]]; then + if [[ "$lc_instance" != "ndp" ]]; then + dir="$(dirname "$REPO_ROOT")/ndp-ckan-$lc_instance" + else + dir="$(dirname "$REPO_ROOT")/ndp-ckan" + fi + fi + [[ -n "$dir" && -f "$dir/docker-compose.yml" ]] && printf '%s' "$dir" + return 0 +} + +lc_containers() { + # lc_containers [-a] -- ids of the project's (running, or all) containers + docker ps -q "$@" --filter "label=com.docker.compose.project=$lc_project" 2>/dev/null || true +} + +lc_port() { + # Found by its project's labels, not by name: another checkout's Endpoint + # can have the same container name. + local port="" api + api="$(docker ps -aq --filter "label=com.docker.compose.project=$lc_project" \ + --filter "label=com.docker.compose.service=api" 2>/dev/null | head -n1 || true)" + [[ -z "$api" ]] || port="$(docker port "$api" 80 2>/dev/null | head -n1 | sed 's/.*://')" + [[ -n "$port" ]] || port="$(lc_state EP_API_PORT)" + printf '%s' "$port" +} + +lc_status() { + local running all port health_url code nb ip version + version="$(lc_state INSTALLER_VERSION)" + [[ -n "$version" ]] || version="$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo unknown)" + running="$(lc_containers | grep -c . || true)" + all="$(lc_containers -a | grep -c . || true)" + port="$(lc_port)" + echo "Endpoint at $REPO_ROOT" + echo " instance: $lc_instance" + echo " compose project: $lc_project" + echo " version: $version" + echo " catalog: $(lc_env LOCAL_CATALOG_BACKEND || true)" + echo " containers: $running running of $all" + echo " port: ${port:-unknown}" + if [[ -n "$port" ]]; then + health_url="http://localhost:${port}$(lc_env ROOT_PATH)/health" + code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 "$health_url" || true)" + if [[ "$code" == "200" ]]; then + echo " health: healthy at $health_url" + else + echo " health: not answering at $health_url" + fi + fi + if ! command -v netbird >/dev/null 2>&1; then + echo " NetBird: client not installed" + else + nb="$(netbird status --json 2>/dev/null || true)" + if [[ "$nb" =~ \"connected\":[[:space:]]*true ]]; then + ip="$(sed -n 's/.*"netbirdIp":[[:space:]]*"\([^"/]*\).*/\1/p' <<< "$nb")" + echo " NetBird: connected${ip:+ ($ip)}" + else + echo " NetBird: not connected" + fi + fi + local ckan; ckan="$(lc_ckan_dir)" + [[ -z "$ckan" ]] || echo " CKAN: $ckan" +} + +lc_stop() { + local ids ckan + ids="$(lc_containers | tr '\n' ' ' | sed 's/ *$//')" + if [[ -z "$ids" ]]; then + ok "Endpoint '$lc_instance' is already stopped." + else + # shellcheck disable=SC2086 + docker stop $ids >/dev/null + ok "Stopped Endpoint '$lc_instance' (Compose project $lc_project)." + fi + ckan="$(lc_ckan_dir)" + if [[ -n "$ckan" ]]; then + (cd "$ckan" && "${lc_compose[@]}" stop >/dev/null 2>&1) && ok "Stopped its CKAN ($ckan)." + fi + return 0 +} + +lc_start() { + local ids ckan port + ids="$(lc_containers -a | tr '\n' ' ' | sed 's/ *$//')" + [[ -n "$ids" ]] || fail "Endpoint '$lc_instance' has no containers (Compose project $lc_project); run the installer to create them." + ckan="$(lc_ckan_dir)" + if [[ -n "$ckan" ]]; then + (cd "$ckan" && "${lc_compose[@]}" start >/dev/null 2>&1) && ok "Started its CKAN ($ckan)." + fi + # Started as they were created, on the ports they were created with. A + # "docker compose up" by hand would publish the default port instead (#370). + # shellcheck disable=SC2086 + docker start $ids >/dev/null + port="$(lc_port)" + ok "Started Endpoint '$lc_instance'${port:+ on port $port}." +} + +lc_uninstall() { + local ids volumes networks ckan reply + info "This removes Endpoint '$lc_instance' (Compose project $lc_project): its containers," + info "its networks and the .env backups, which hold old credentials." + if [[ "$remove_data" == "true" ]]; then + warn "--remove-data: its volumes -- the catalog, the files in MinIO, everything it stored --" + warn "and its .env and install state are deleted too. This cannot be undone." + else + info "Its data, .env and install state are kept (--remove-data deletes them)." + fi + if [[ "$assume_yes" != "true" ]]; then + [[ -t 0 ]] || fail "uninstall asks for confirmation; run it with --yes where nobody can answer." + read -r -p "Remove it? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || fail "Aborted; nothing was changed." + fi + + ids="$(lc_containers -a | tr '\n' ' ' | sed 's/ *$//')" + # shellcheck disable=SC2086 + [[ -z "$ids" ]] || docker rm -f $ids >/dev/null + networks="$(docker network ls -q --filter "label=com.docker.compose.project=$lc_project" 2>/dev/null | tr '\n' ' ' | sed 's/ *$//' || true)" + # shellcheck disable=SC2086 + [[ -z "$networks" ]] || docker network rm $networks >/dev/null 2>&1 || true + ok "Removed its containers and networks." + + ckan="$(lc_ckan_dir)" + if [[ -n "$ckan" && "$(lc_state CKAN_CREATED)" == "yes" ]]; then + if [[ "$remove_data" == "true" ]]; then + (cd "$ckan" && "${lc_compose[@]}" down -v >/dev/null 2>&1) || true + ok "Removed the CKAN installed for it, with its data; its files remain: rm -rf $ckan" + else + (cd "$ckan" && "${lc_compose[@]}" down >/dev/null 2>&1) || true + ok "Removed the CKAN containers installed for it; its data is kept, files at $ckan" + fi + elif [[ -n "$ckan" ]]; then + info "The CKAN at $ckan was not created by this installer; left as it is." + fi + + rm -f "$REPO_ROOT"/.env.backup.* + ok "Removed the .env backups." + + if [[ "$remove_data" == "true" ]]; then + volumes="$(docker volume ls -q --filter "label=com.docker.compose.project=$lc_project" 2>/dev/null | tr '\n' ' ' | sed 's/ *$//' || true)" + # shellcheck disable=SC2086 + [[ -z "$volumes" ]] || docker volume rm $volumes >/dev/null + rm -f "$REPO_ROOT/.env" "$REPO_ROOT/.env.install-state" + ok "Removed its data volumes, .env and install state." + else + ok "Data volumes, .env and install state kept; the installer reuses them." + fi + + if [[ "$(lc_state NETBIRD)" == "yes" || "$remove_data" == "true" ]] && command -v netbird >/dev/null 2>&1; then + info "This machine stays in NDP's NetBird network, which other Endpoints on it may use." + info "To leave it: sudo netbird down (and sudo netbird service uninstall to remove the client)." + fi + info "The checkout is left at $REPO_ROOT; remove it when no longer needed." +} + +if [[ -z "$subcommand" && "$remove_data" == "true" ]]; then + fail "--remove-data only goes with uninstall." +fi + +if [[ -n "$subcommand" ]]; then + command -v docker >/dev/null 2>&1 || fail "docker is required." + if docker compose version >/dev/null 2>&1; then + lc_compose=(docker compose) + else + lc_compose=(docker-compose) + fi + lc_instance="$instance" + [[ -n "$lc_instance" ]] || lc_instance="$(lc_state EP_INSTANCE)" + [[ -n "$lc_instance" ]] || lc_instance="ndp" + lc_project="$(lc_project)" + case "$subcommand" in + status) lc_status ;; + stop) lc_stop ;; + start) lc_start ;; + uninstall) lc_uninstall ;; + esac + exit 0 +fi + banner # Which Federation this run will talk to, before anything happens. The script @@ -1876,6 +2138,8 @@ else info "Cloning $ckan_repo into $ckan_dir" git clone --depth 1 "$ckan_repo" "$ckan_dir" \ || fail "Could not clone the CKAN repository." + # Created here, so uninstall may remove it (issue #372). + state_set CKAN_CREATED yes else info "CKAN checkout already present at $ckan_dir" fi @@ -1947,6 +2211,7 @@ else state_set CKAN_API_KEY "$ckan_api_key" state_set CKAN_URL "$ckan_site_url" + state_set CKAN_DIR "$ckan_dir" state_set CKAN_SYSADMIN "$ckan_sysadmin" state_set CKAN_PASSWORD "$ckan_password" ok "Token minted and saved to $(basename "$STATE_FILE") so re-running does not create another" @@ -2042,6 +2307,8 @@ fi # Which installer this is, for support and for a later status (issue #371). installer_version="$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo unknown)" [[ "$dry_run" == "true" ]] || state_set INSTALLER_VERSION "$installer_version" +# The published port, which status and start report (issue #372). +[[ "$dry_run" == "true" ]] || state_set EP_API_PORT "$ep_api_port" # -------------------------------------------------------------- step "Rendering .env from example.env" diff --git a/install/tests/test_lifecycle_commands.py b/install/tests/test_lifecycle_commands.py new file mode 100644 index 0000000..48a4424 --- /dev/null +++ b/install/tests/test_lifecycle_commands.py @@ -0,0 +1,318 @@ +""" +``status``, ``stop``, ``start`` and ``uninstall`` (issue #372). + +The installer could only install. Stopping an Endpoint meant guessing its +Compose project and port, removing one was a manual hunt through containers, +volumes and ``.env.backup.*`` files (which hold credentials), and support could +not ask "what is running here?" without reading ``.env`` by hand. + +The subcommands act on the Endpoint the checkout already holds, through the +Compose project's labels, so no port or profile has to be remembered. Data is +only removed with ``uninstall --remove-data``. + +``docker`` is a stub that answers from files and logs what it is asked. +""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BASH = shutil.which("bash") + +pytestmark = pytest.mark.skipif( + BASH is None or os.name == "nt" or shutil.which("curl") is None, + reason="needs bash and curl", +) + + +@pytest.fixture +def endpoint(tmp_path): + """An installed checkout and a docker that knows its containers.""" + root = tmp_path / "ndp-ep" + shutil.copytree(REPO / "install", root / "install") + shutil.copy(REPO / "example.env", root / "example.env") + shutil.copy(REPO / "docker-compose.yml", root / "docker-compose.yml") + (root / ".env").write_text( + "LOCAL_CATALOG_BACKEND=mongodb\nROOT_PATH=\nS3_SECRET_KEY=secret-xyz\n", + encoding="utf-8", + ) + (root / ".env.install-state").write_text( + "EP_INSTANCE=ndp\nINSTALLER_VERSION=v0.34.62\nNETBIRD=yes\nEP_API_PORT=8099\n", + encoding="utf-8", + ) + (root / ".env.backup.20261001-101010").write_text("OLD=1\n", encoding="utf-8") + stubs = tmp_path / "bin" + stubs.mkdir() + for name, content in { + "running.txt": "c1\nc2\n", + "all.txt": "c1\nc2\n", + "volumes.txt": "ndp-ep_mongodb_data\n", + "networks.txt": "ndp-ep_ndp-network\n", + }.items(): + (tmp_path / name).write_text(content, encoding="utf-8") + log = tmp_path / "docker.log" + (stubs / "docker").write_text( + "#!/bin/sh\n" + f'echo "docker $*" >> "{log}"\n' + f'T="{tmp_path}"\n' + 'case "$1" in\n' + ' ps) case " $* " in *" -a "*|*" -aq "*) cat "$T/all.txt" ;; *) cat "$T/running.txt" ;; esac ;;\n' + ' volume) [ "$2" = ls ] && cat "$T/volumes.txt" ;;\n' + ' network) [ "$2" = ls ] && cat "$T/networks.txt" ;;\n' + " port) echo 0.0.0.0:8099 ;;\n" + "esac\n" + "exit 0\n", + encoding="utf-8", + ) + (stubs / "netbird").write_text( + "#!/bin/sh\n" + 'printf \'{"management":{"url":"https://nb.example.org","connected":true},' + '"netbirdIp":"100.64.0.9/16"}\'\n', + encoding="utf-8", + ) + for stub in stubs.iterdir(): + stub.chmod(0o755) + return {"root": root, "stubs": stubs, "log": log, "tmp": tmp_path} + + +def _run(endpoint, *args, script=None): + return subprocess.run( + [BASH, str(script or endpoint["root"] / "install" / "install.sh"), *args], + capture_output=True, + text=True, + timeout=120, + env={ + **os.environ, + "PATH": f"{endpoint['stubs']}{os.pathsep}{os.environ['PATH']}", + "EP_INSTALL_DIR": str(endpoint["root"]), + }, + stdin=subprocess.DEVNULL, + ) + + +def _calls(endpoint): + return endpoint["log"].read_text() if endpoint["log"].exists() else "" + + +class TestStatus: + def test_it_reports_what_is_installed(self, endpoint): + result = _run(endpoint, "status") + + assert result.returncode == 0, result.stdout + result.stderr + out = result.stdout + assert "instance: ndp" in out + assert "compose project: ndp-ep" in out + assert "version: v0.34.62" in out + assert "catalog: mongodb" in out + assert "containers: 2 running of 2" in out + assert "port: 8099" in out + assert "NetBird: connected (100.64.0.9)" in out + + def test_it_says_when_the_endpoint_does_not_answer(self, endpoint): + result = _run(endpoint, "status") + + assert "health: not answering at http://localhost:8099/health" in result.stdout + + def test_it_changes_nothing(self, endpoint): + _run(endpoint, "status") + + calls = _calls(endpoint) + for action in ( + "docker stop", + "docker start", + "docker rm", + "docker volume rm", + "docker network rm", + "compose up", + "compose down", + ): + assert action not in calls + + def test_it_never_prints_secrets(self, endpoint): + result = _run(endpoint, "status") + + assert "secret-xyz" not in result.stdout + result.stderr + + def test_it_leaves_no_log_behind(self, endpoint): + _run(endpoint, "status") + + assert not (endpoint["root"] / "install.log").exists() + + def test_it_does_not_install_anything(self, endpoint): + result = _run(endpoint, "status") + + assert "Checking prerequisites" not in result.stdout + assert "Rendering .env" not in result.stdout + + +class TestStopAndStart: + def test_stop_stops_the_projects_containers(self, endpoint): + result = _run(endpoint, "stop") + + assert result.returncode == 0, result.stdout + result.stderr + calls = _calls(endpoint) + assert "label=com.docker.compose.project=ndp-ep" in calls + assert "docker stop c1 c2" in calls + + def test_stop_when_nothing_runs(self, endpoint): + (endpoint["tmp"] / "running.txt").write_text("") + + result = _run(endpoint, "stop") + + assert result.returncode == 0 + assert "already stopped" in result.stdout + assert "docker stop" not in _calls(endpoint) + + def test_start_starts_the_same_containers(self, endpoint): + """Started as they were created, so the port stays (issue #370).""" + result = _run(endpoint, "start") + + calls = _calls(endpoint) + assert "docker start c1 c2" in calls + assert "compose up" not in calls + assert "8099" in result.stdout + result.stderr + + def test_start_without_containers_points_at_the_installer(self, endpoint): + (endpoint["tmp"] / "all.txt").write_text("") + + result = _run(endpoint, "start") + + assert result.returncode != 0 + assert "run the installer" in result.stderr + + +class TestUninstall: + def test_it_needs_confirmation(self, endpoint): + result = _run(endpoint, "uninstall") + + assert result.returncode != 0 + assert "--yes" in result.stderr + assert "docker rm" not in _calls(endpoint) + + def test_it_removes_containers_and_networks_but_keeps_data(self, endpoint): + result = _run(endpoint, "uninstall", "--yes") + + assert result.returncode == 0, result.stdout + result.stderr + calls = _calls(endpoint) + assert "docker rm -f c1 c2" in calls + assert "docker network rm ndp-ep_ndp-network" in calls + assert "docker volume rm" not in calls + assert "kept" in result.stdout + + def test_it_removes_the_env_backups_that_hold_old_secrets(self, endpoint): + _run(endpoint, "uninstall", "--yes") + + assert not list(endpoint["root"].glob(".env.backup.*")) + + def test_without_remove_data_what_opens_the_data_stays(self, endpoint): + """The install state holds the generated passwords the data needs.""" + _run(endpoint, "uninstall", "--yes") + + assert (endpoint["root"] / ".env.install-state").exists() + assert (endpoint["root"] / ".env").exists() + + def test_remove_data_removes_volumes_and_credentials(self, endpoint): + result = _run(endpoint, "uninstall", "--remove-data", "--yes") + + assert result.returncode == 0, result.stdout + result.stderr + assert "docker volume rm ndp-ep_mongodb_data" in _calls(endpoint) + assert not (endpoint["root"] / ".env.install-state").exists() + assert not (endpoint["root"] / ".env").exists() + + def test_netbird_is_left_and_how_to_leave_is_said(self, endpoint): + result = _run(endpoint, "uninstall", "--yes") + + assert "netbird down" in result.stdout + + def test_the_checkout_itself_is_left(self, endpoint): + _run(endpoint, "uninstall", "--remove-data", "--yes") + + assert (endpoint["root"] / "install" / "install.sh").exists() + + +def test_another_checkouts_endpoint_is_not_reported_as_this_one(endpoint): + """No containers of this project: no port from a same-named container.""" + (endpoint["tmp"] / "all.txt").write_text("") + (endpoint["tmp"] / "running.txt").write_text("") + state = endpoint["root"] / ".env.install-state" + state.write_text(state.read_text().replace("EP_API_PORT=8099\n", "")) + + result = _run(endpoint, "status") + + assert "port: unknown" in result.stdout + assert "docker port ndp-ep-api" not in _calls(endpoint) + + +class TestNamedInstances: + def test_the_project_follows_the_instance(self, endpoint): + state = endpoint["root"] / ".env.install-state" + state.write_text( + state.read_text().replace("EP_INSTANCE=ndp", "EP_INSTANCE=glsr") + ) + + result = _run(endpoint, "status") + + assert "instance: glsr" in result.stdout + assert "compose project: ndp-ep-glsr" in result.stdout + + +def test_from_the_web_it_acts_on_the_installed_checkout(endpoint): + """Piped, it must not fetch or move the checkout: it only looks at it.""" + lone = endpoint["tmp"] / "piped" / "install.sh" + lone.parent.mkdir() + shutil.copy(REPO / "install" / "install.sh", lone) + + result = _run(endpoint, "status", script=lone) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Fetching" not in result.stdout + assert f"Endpoint at {endpoint['root']}" in result.stdout + + +def test_from_the_web_without_an_installation_it_says_so(endpoint, tmp_path): + lone = tmp_path / "piped" / "install.sh" + lone.parent.mkdir() + shutil.copy(REPO / "install" / "install.sh", lone) + shutil.rmtree(endpoint["root"]) + + result = _run(endpoint, "stop", script=lone) + + assert result.returncode != 0 + assert "No Endpoint installed" in result.stderr + + +def test_an_install_records_its_port(tmp_path): + root = tmp_path / "ep-api" + shutil.copytree(REPO / "install", root / "install") + shutil.copy(REPO / "example.env", root / "example.env") + shutil.copy(REPO / "docker-compose.yml", root / "docker-compose.yml") + stubs = tmp_path / "bin" + stubs.mkdir() + (stubs / "docker").write_text("#!/bin/sh\nexit 0\n") + (stubs / "docker").chmod(0o755) + + subprocess.run( + [ + BASH, + str(root / "install" / "install.sh"), + "--backend", + "none", + "--no-netbird", + "--ep-api-port", + "8123", + "--yes", + "--no-start", + ], + capture_output=True, + text=True, + timeout=120, + env={**os.environ, "PATH": f"{stubs}{os.pathsep}{os.environ['PATH']}"}, + stdin=subprocess.DEVNULL, + ) + + state = (root / ".env.install-state").read_text() + assert "EP_API_PORT=8123" in state.splitlines()