From 5268687d1068d8522fbf9f61e695c2e4d23618a4 Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Thu, 12 Feb 2026 10:59:14 +0100 Subject: [PATCH 01/10] Add E2E lifecycle test for create/destroy workflow Bash script that exercises the full droplet lifecycle: create (no Tailscale), verify SSH config, run commands over SSH, destroy, and verify cleanup. Designed for CI reuse with env-var overrides and trap-based cleanup on failure. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/test_lifecycle.sh | 276 ++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100755 tests/e2e/test_lifecycle.sh diff --git a/tests/e2e/test_lifecycle.sh b/tests/e2e/test_lifecycle.sh new file mode 100755 index 0000000..f98e800 --- /dev/null +++ b/tests/e2e/test_lifecycle.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# +# E2E test: dropkit create → SSH commands → dropkit destroy +# +# Verifies the full droplet lifecycle including SSH config management. +# Designed to run in CI or locally — requires a valid dropkit config. +# +# Usage: +# ./tests/e2e/test_lifecycle.sh +# +# Environment variables (all optional, uses config defaults if unset): +# DROPLET_NAME — Name for the test droplet (default: e2e-) +# DROPLET_REGION — Region slug (default: from config) +# DROPLET_SIZE — Size slug (default: from config) +# DROPLET_IMAGE — Image slug (default: from config) +# E2E_SSH_TIMEOUT — SSH connect timeout in seconds (default: 10) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DROPLET_NAME="${DROPLET_NAME:-e2e-$(head -c4 /dev/urandom | od -An -tx1 | tr -d ' \n')}" +SSH_HOSTNAME="dropkit.${DROPLET_NAME}" +SSH_CONFIG="${HOME}/.ssh/config" +SSH_TIMEOUT="${E2E_SSH_TIMEOUT:-10}" +SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=${SSH_TIMEOUT} -o BatchMode=yes" + +# Read defaults from dropkit config if env vars not set +DROPKIT_CONFIG="${HOME}/.config/dropkit/config.yaml" +if [[ -z "${DROPLET_REGION:-}" || -z "${DROPLET_SIZE:-}" || -z "${DROPLET_IMAGE:-}" ]]; then + if [[ ! -f "${DROPKIT_CONFIG}" ]]; then + echo "Error: ${DROPKIT_CONFIG} not found and DROPLET_REGION/SIZE/IMAGE not all set" + exit 1 + fi + # Parse YAML defaults (simple grep — avoids adding a yq dependency) + _cfg_val() { grep "^ $1:" "${DROPKIT_CONFIG}" | head -1 | awk '{print $2}' | tr -d '"'"'"; } + DROPLET_REGION="${DROPLET_REGION:-$(_cfg_val region)}" + DROPLET_SIZE="${DROPLET_SIZE:-$(_cfg_val size)}" + DROPLET_IMAGE="${DROPLET_IMAGE:-$(_cfg_val image)}" +fi + +CREATE_FLAGS=( + --no-tailscale --verbose + --region "$DROPLET_REGION" + --size "$DROPLET_SIZE" + --image "$DROPLET_IMAGE" +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +TESTS_PASSED=0 +TESTS_FAILED=0 +DROPLET_CREATED=false + +log() { echo -e "${DIM}[$(date +%H:%M:%S)]${NC} $*"; } +log_step() { echo -e "\n${BOLD}${CYAN}=== $* ===${NC}"; } +log_ok() { echo -e " ${GREEN}✓${NC} $*"; } +log_fail() { echo -e " ${RED}✗${NC} $*"; } +log_warn() { echo -e " ${YELLOW}!${NC} $*"; } + +assert() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + log_ok "PASS: ${description}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "FAIL: ${description}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_file_contains() { + local description="$1" file="$2" pattern="$3" + if grep -qF "$pattern" "$file" 2>/dev/null; then + log_ok "PASS: ${description}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "FAIL: ${description} — '${pattern}' not found in ${file}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +assert_file_not_contains() { + local description="$1" file="$2" pattern="$3" + if ! grep -qF "$pattern" "$file" 2>/dev/null; then + log_ok "PASS: ${description}" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "FAIL: ${description} — '${pattern}' unexpectedly found in ${file}" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +} + +ssh_run() { + # shellcheck disable=SC2086 + ssh ${SSH_OPTS} "${SSH_HOSTNAME}" "$@" 2>&1 +} + +cleanup() { + if [[ "${DROPLET_CREATED}" == "true" ]]; then + echo "" + log_warn "Cleanup: destroying droplet ${DROPLET_NAME}..." + printf 'yes\n%s\ny\n' "${DROPLET_NAME}" \ + | uv run dropkit destroy "${DROPLET_NAME}" 2>&1 || true + DROPLET_CREATED=false + fi +} + +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Pre-flight +# --------------------------------------------------------------------------- + +log_step "Pre-flight checks" + +log "Droplet name : ${DROPLET_NAME}" +log "SSH hostname : ${SSH_HOSTNAME}" +log "SSH config : ${SSH_CONFIG}" +log "Create flags : ${CREATE_FLAGS[*]}" +log "" + +assert "dropkit is installed" uv run dropkit version +assert "SSH config file exists" test -f "${SSH_CONFIG}" + +# Ensure no leftover entry from a previous failed run +if grep -qF "Host ${SSH_HOSTNAME}" "${SSH_CONFIG}" 2>/dev/null; then + log_warn "Stale SSH entry found for ${SSH_HOSTNAME} — aborting to avoid conflicts" + log_warn "Remove it manually or pick a different DROPLET_NAME" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Step 1: Create droplet +# --------------------------------------------------------------------------- + +log_step "Step 1: Create droplet" + +uv run dropkit create "${DROPLET_NAME}" "${CREATE_FLAGS[@]}" +DROPLET_CREATED=true + +log "Droplet created." + +# --------------------------------------------------------------------------- +# Step 2: Verify SSH config after create +# --------------------------------------------------------------------------- + +log_step "Step 2: Verify SSH config (post-create)" + +assert_file_contains \ + "SSH config contains Host entry for droplet" \ + "${SSH_CONFIG}" "Host ${SSH_HOSTNAME}" + +assert_file_contains \ + "SSH config entry has ForwardAgent yes" \ + "${SSH_CONFIG}" "ForwardAgent yes" + +# Extract the IP that was written to SSH config +DROPLET_IP=$(grep -A5 "Host ${SSH_HOSTNAME}" "${SSH_CONFIG}" \ + | grep "HostName" | head -1 | awk '{print $2}') + +if [[ -z "${DROPLET_IP}" ]]; then + log_fail "Could not extract droplet IP from SSH config" + ((TESTS_FAILED++)) +else + log "Droplet IP: ${DROPLET_IP}" + assert "Droplet IP looks like an IPv4 address" \ + bash -c "[[ '${DROPLET_IP}' =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]" +fi + +# --------------------------------------------------------------------------- +# Step 3: Run commands on the droplet via SSH +# --------------------------------------------------------------------------- + +log_step "Step 3: Run commands on the droplet" + +# Basic connectivity +output=$(ssh_run "echo 'hello-from-droplet'") +assert "SSH echo returns expected string" bash -c "[[ '${output}' == *hello-from-droplet* ]]" + +# Kernel check +uname_output=$(ssh_run "uname -s") +assert "Remote OS is Linux" bash -c "[[ '${uname_output}' == *Linux* ]]" + +# Uptime (proves the system is live and responding) +uptime_output=$(ssh_run "uptime") +log "Remote uptime: ${uptime_output}" +assert "uptime command succeeds" test -n "${uptime_output}" + +# Disk space +df_output=$(ssh_run "df -h /") +log "Remote disk:" +echo "${df_output}" | while IFS= read -r line; do log " ${line}"; done +assert "df reports a filesystem" bash -c "[[ '${df_output}' == */* ]]" + +# Cloud-init final status (parse JSON regardless of exit code per CLAUDE.md) +cloud_init_output=$(ssh_run "cloud-init status --format=json" || true) +log "Cloud-init status: ${cloud_init_output}" +assert "Cloud-init reports done" \ + bash -c "echo '${cloud_init_output}' | grep -q '\"done\"'" + +# --------------------------------------------------------------------------- +# Step 4: Destroy droplet +# --------------------------------------------------------------------------- + +log_step "Step 4: Destroy droplet" + +# Answers: 1) "yes" to confirm 2) droplet name 3) "y" to remove known_hosts +printf 'yes\n%s\ny\n' "${DROPLET_NAME}" \ + | uv run dropkit destroy "${DROPLET_NAME}" +DROPLET_CREATED=false + +log "Droplet destroyed." + +# --------------------------------------------------------------------------- +# Step 5: Verify SSH config after destroy +# --------------------------------------------------------------------------- + +log_step "Step 5: Verify SSH config (post-destroy)" + +assert_file_not_contains \ + "SSH config no longer contains Host entry" \ + "${SSH_CONFIG}" "Host ${SSH_HOSTNAME}" + +if [[ -n "${DROPLET_IP:-}" ]]; then + assert_file_not_contains \ + "SSH config no longer references droplet IP" \ + "${SSH_CONFIG}" "HostName ${DROPLET_IP}" +fi + +# Verify known_hosts was cleaned up (best-effort — entry may have been hashed) +KNOWN_HOSTS="${HOME}/.ssh/known_hosts" +if [[ -f "${KNOWN_HOSTS}" ]]; then + assert_file_not_contains \ + "known_hosts does not contain SSH hostname" \ + "${KNOWN_HOSTS}" "${SSH_HOSTNAME}" + + if [[ -n "${DROPLET_IP:-}" ]]; then + assert_file_not_contains \ + "known_hosts does not contain droplet IP" \ + "${KNOWN_HOSTS}" "${DROPLET_IP}" + fi +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +log_step "Results" + +TOTAL=$((TESTS_PASSED + TESTS_FAILED)) +echo "" +log "Passed : ${TESTS_PASSED}/${TOTAL}" +log "Failed : ${TESTS_FAILED}/${TOTAL}" +echo "" + +if [[ "${TESTS_FAILED}" -gt 0 ]]; then + echo -e "${RED}${BOLD}SOME TESTS FAILED${NC}" + exit 1 +fi + +echo -e "${GREEN}${BOLD}ALL TESTS PASSED${NC}" +exit 0 From 1eb1cd9f59f275caa1efd5f3789c98190a3481b9 Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Thu, 12 Feb 2026 10:59:50 +0100 Subject: [PATCH 02/10] Add weekly E2E workflow on GitHub Actions Runs the lifecycle test every Monday at 06:00 UTC (also manually triggerable via workflow_dispatch). Uses the smallest droplet size to minimize cost. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yml | 50 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..5a9547a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,50 @@ +name: E2E + +on: + schedule: + # Every Monday at 06:00 UTC + - cron: "0 6 * * 1" + workflow_dispatch: # Allow manual triggers + +permissions: + contents: read + +jobs: + lifecycle: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync + + - name: Set up SSH key + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + echo "${{ secrets.E2E_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub + touch ~/.ssh/config && chmod 600 ~/.ssh/config + + - name: Set up dropkit config + run: | + mkdir -p ~/.config/dropkit + echo "${{ secrets.E2E_DROPKIT_CONFIG }}" > ~/.config/dropkit/config.yaml + chmod 600 ~/.config/dropkit/config.yaml + + - name: Run E2E lifecycle test + run: ./tests/e2e/test_lifecycle.sh + env: + DROPLET_SIZE: s-1vcpu-1gb + timeout-minutes: 15 From 7dbdd0aab4f63d64e9cf46a649f458438ecc1031 Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Mon, 23 Feb 2026 10:18:53 +0100 Subject: [PATCH 03/10] Fix Jinja2 escaping of zsh PROMPT in cloud-init template The zsh PROMPT string contains `%` sequences that Jinja2 interprets as template syntax. Wrap in {% raw %}...{% endraw %} to preserve the literal prompt definition. Co-Authored-By: Claude Opus 4.6 --- dropkit/templates/default-cloud-init.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dropkit/templates/default-cloud-init.yaml b/dropkit/templates/default-cloud-init.yaml index 828e1c4..6321521 100644 --- a/dropkit/templates/default-cloud-init.yaml +++ b/dropkit/templates/default-cloud-init.yaml @@ -47,7 +47,7 @@ write_files: defer: true content: | # Prompt: user@host:dir (green for normal, red after failed command) - PROMPT='%F{%(?.green.red)}%n@%m%f:%F{blue}%~%f$ ' + {% raw %}PROMPT='%F{%(?.green.red)}%n@%m%f:%F{blue}%~%f$ '{% endraw %} # History HISTFILE=~/.zsh_history From 329f6fdc62107f57905cc69c406ed3cbad34974e Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Mon, 23 Feb 2026 12:02:13 +0100 Subject: [PATCH 04/10] Document E2E testing in CLAUDE.md, README.md, and Makefile Add E2E test requirement to critical rules, document the test in both CLAUDE.md and README.md development sections, and add a `make e2e` target. Fix help grep pattern to include digits so `e2e` appears. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 16 ++++++++++++++++ Makefile | 7 +++++-- README.md | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7e64e82..363441d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ Pre-configured cloud-init, Tailscale VPN (enabled by default), and SSH config ma - **Never use `pip`** — always use `uv` for all Python operations - **Always run `prek run`** before committing (or `prek install` to auto-run on commit) - **Keep README.md in sync** when adding commands or features +- **Run E2E tests before pushing** changes that affect core workflows (create, destroy, SSH config, cloud-init, Tailscale) ## Quick Commands @@ -26,6 +27,7 @@ dropkit/ ├── dropkit/ # CLI source (Typer entry point: main.py) │ └── templates/ # Jinja2 cloud-init templates └── tests/ # pytest tests + └── e2e/ ``` ## Technology Stack @@ -140,6 +142,20 @@ uv run pytest -v # Verbose **Coverage**: Minimum 29% enforced via `--cov-fail-under=29` in pyproject.toml. +### E2E Testing + +The E2E lifecycle test creates a real droplet, verifies SSH connectivity, +and destroys it. **Run before pushing changes that affect core workflows** +(create, destroy, SSH config, cloud-init, Tailscale). + +```bash +./tests/e2e/test_lifecycle.sh +``` + +Requires a valid dropkit config (`~/.config/dropkit/config.yaml`). +Optional environment variables: `DROPLET_NAME`, `DROPLET_REGION`, +`DROPLET_SIZE`, `DROPLET_IMAGE`, `E2E_SSH_TIMEOUT`. + ## Pydantic Models - **`DropkitConfig`** — Root config with `extra='forbid'` diff --git a/Makefile b/Makefile index c0fb9d6..23c1f8c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: help dev lint format test audit +.PHONY: help dev lint format test e2e audit help: ## Show available targets - @grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk -F ':.*## ' '{printf " %-12s %s\n", $$1, $$2}' + @grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | awk -F ':.*## ' '{printf " %-12s %s\n", $$1, $$2}' dev: ## Install all dependencies uv sync --all-groups @@ -15,5 +15,8 @@ format: ## Auto-format code test: ## Run tests uv run pytest +e2e: ## Run E2E lifecycle test (creates a real droplet) + ./tests/e2e/test_lifecycle.sh + audit: ## Audit dependencies for vulnerabilities uv run pip-audit diff --git a/README.md b/README.md index 786bbe4..f00d507 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,29 @@ The droplet might belong to someone else. List your droplets: dropkit list ``` +## Development + +### Running Unit Tests + +```bash +uv run pytest # All tests +uv run pytest -v # Verbose output +uv run pytest -k "pattern" # Filter by name +``` + +### Running E2E Tests + +The E2E lifecycle test creates a real droplet, verifies SSH connectivity, +and destroys it. Run before pushing changes that affect core workflows. + +```bash +./tests/e2e/test_lifecycle.sh +``` + +Requires a valid dropkit config (`~/.config/dropkit/config.yaml`). +Optional environment variables: `DROPLET_NAME`, `DROPLET_REGION`, +`DROPLET_SIZE`, `DROPLET_IMAGE`, `E2E_SSH_TIMEOUT`. + ## Technology Stack - **CLI Framework**: [Typer](https://typer.tiangolo.com/) - Modern CLI framework From 6ce309f1d868627891ecfec4dd916a888446cc38 Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Thu, 19 Mar 2026 18:07:22 +0100 Subject: [PATCH 05/10] Replace CI E2E workflow with pre-push prek hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the GitHub Actions E2E workflow — storing DO credentials in CI is not feasible securely. Instead, add the E2E lifecycle test as a pre-push hook via prek so developers run it locally with their own credentials before pushing. Hardcode test defaults (nyc3, s-1vcpu-1gb, ubuntu-24-04-x64) so the tests are deterministic and don't depend on user config values. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e.yml | 50 ------------------------------------- .pre-commit-config.yaml | 11 ++++++++ CLAUDE.md | 14 ++++++----- README.md | 12 ++++++--- tests/e2e/test_lifecycle.sh | 25 ++++++------------- 5 files changed, 35 insertions(+), 77 deletions(-) delete mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 5a9547a..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: E2E - -on: - schedule: - # Every Monday at 06:00 UTC - - cron: "0 6 * * 1" - workflow_dispatch: # Allow manual triggers - -permissions: - contents: read - -jobs: - lifecycle: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Install uv - uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0 - with: - enable-cache: true - - - name: Set up Python - run: uv python install 3.13 - - - name: Install dependencies - run: uv sync - - - name: Set up SSH key - run: | - mkdir -p ~/.ssh && chmod 700 ~/.ssh - echo "${{ secrets.E2E_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub - touch ~/.ssh/config && chmod 600 ~/.ssh/config - - - name: Set up dropkit config - run: | - mkdir -p ~/.config/dropkit - echo "${{ secrets.E2E_DROPKIT_CONFIG }}" > ~/.config/dropkit/config.yaml - chmod 600 ~/.config/dropkit/config.yaml - - - name: Run E2E lifecycle test - run: ./tests/e2e/test_lifecycle.sh - env: - DROPLET_SIZE: s-1vcpu-1gb - timeout-minutes: 15 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4d3c20..62d5828 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,3 +40,14 @@ repos: hooks: - id: shellcheck args: [--severity=error] + + # E2E lifecycle test (creates a real droplet, requires valid config) + - repo: local + hooks: + - id: e2e-lifecycle + name: E2E lifecycle test + entry: ./tests/e2e/test_lifecycle.sh + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/CLAUDE.md b/CLAUDE.md index 363441d..ce47b31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Pre-configured cloud-init, Tailscale VPN (enabled by default), and SSH config ma - **Never use `pip`** — always use `uv` for all Python operations - **Always run `prek run`** before committing (or `prek install` to auto-run on commit) - **Keep README.md in sync** when adding commands or features -- **Run E2E tests before pushing** changes that affect core workflows (create, destroy, SSH config, cloud-init, Tailscale) +- **E2E tests run automatically on `pre-push`** via prek — they create a real droplet with hardcoded defaults ## Quick Commands @@ -145,15 +145,17 @@ uv run pytest -v # Verbose ### E2E Testing The E2E lifecycle test creates a real droplet, verifies SSH connectivity, -and destroys it. **Run before pushing changes that affect core workflows** -(create, destroy, SSH config, cloud-init, Tailscale). +and destroys it. It runs automatically as a **pre-push hook** via prek, +using hardcoded defaults (nyc3, s-1vcpu-1gb, ubuntu-24-04-x64) to avoid +dependence on user config values. ```bash -./tests/e2e/test_lifecycle.sh +./tests/e2e/test_lifecycle.sh # Run manually +prek run --hook-stage pre-push # Run via prek ``` -Requires a valid dropkit config (`~/.config/dropkit/config.yaml`). -Optional environment variables: `DROPLET_NAME`, `DROPLET_REGION`, +Requires a valid dropkit config (`~/.config/dropkit/config.yaml`) with a +DigitalOcean API token. Optional overrides: `DROPLET_NAME`, `DROPLET_REGION`, `DROPLET_SIZE`, `DROPLET_IMAGE`, `E2E_SSH_TIMEOUT`. ## Pydantic Models diff --git a/README.md b/README.md index f00d507..7166d61 100644 --- a/README.md +++ b/README.md @@ -230,14 +230,18 @@ uv run pytest -k "pattern" # Filter by name ### Running E2E Tests The E2E lifecycle test creates a real droplet, verifies SSH connectivity, -and destroys it. Run before pushing changes that affect core workflows. +and destroys it. It runs automatically as a `pre-push` hook via prek. ```bash -./tests/e2e/test_lifecycle.sh +./tests/e2e/test_lifecycle.sh # Run manually +prek run --hook-stage pre-push # Run via prek ``` -Requires a valid dropkit config (`~/.config/dropkit/config.yaml`). -Optional environment variables: `DROPLET_NAME`, `DROPLET_REGION`, +Requires a valid dropkit config (`~/.config/dropkit/config.yaml`) with a +DigitalOcean API token. The test uses hardcoded defaults (nyc3, s-1vcpu-1gb, +ubuntu-24-04-x64) so user config defaults don't affect test behavior. + +Optional environment variable overrides: `DROPLET_NAME`, `DROPLET_REGION`, `DROPLET_SIZE`, `DROPLET_IMAGE`, `E2E_SSH_TIMEOUT`. ## Technology Stack diff --git a/tests/e2e/test_lifecycle.sh b/tests/e2e/test_lifecycle.sh index f98e800..4e32153 100755 --- a/tests/e2e/test_lifecycle.sh +++ b/tests/e2e/test_lifecycle.sh @@ -8,11 +8,11 @@ # Usage: # ./tests/e2e/test_lifecycle.sh # -# Environment variables (all optional, uses config defaults if unset): +# Environment variables (all optional): # DROPLET_NAME — Name for the test droplet (default: e2e-) -# DROPLET_REGION — Region slug (default: from config) -# DROPLET_SIZE — Size slug (default: from config) -# DROPLET_IMAGE — Image slug (default: from config) +# DROPLET_REGION — Region slug (default: nyc3) +# DROPLET_SIZE — Size slug (default: s-1vcpu-1gb) +# DROPLET_IMAGE — Image slug (default: ubuntu-24-04-x64) # E2E_SSH_TIMEOUT — SSH connect timeout in seconds (default: 10) set -euo pipefail @@ -27,19 +27,10 @@ SSH_CONFIG="${HOME}/.ssh/config" SSH_TIMEOUT="${E2E_SSH_TIMEOUT:-10}" SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=${SSH_TIMEOUT} -o BatchMode=yes" -# Read defaults from dropkit config if env vars not set -DROPKIT_CONFIG="${HOME}/.config/dropkit/config.yaml" -if [[ -z "${DROPLET_REGION:-}" || -z "${DROPLET_SIZE:-}" || -z "${DROPLET_IMAGE:-}" ]]; then - if [[ ! -f "${DROPKIT_CONFIG}" ]]; then - echo "Error: ${DROPKIT_CONFIG} not found and DROPLET_REGION/SIZE/IMAGE not all set" - exit 1 - fi - # Parse YAML defaults (simple grep — avoids adding a yq dependency) - _cfg_val() { grep "^ $1:" "${DROPKIT_CONFIG}" | head -1 | awk '{print $2}' | tr -d '"'"'"; } - DROPLET_REGION="${DROPLET_REGION:-$(_cfg_val region)}" - DROPLET_SIZE="${DROPLET_SIZE:-$(_cfg_val size)}" - DROPLET_IMAGE="${DROPLET_IMAGE:-$(_cfg_val image)}" -fi +# Hardcoded defaults — tests should not depend on user config +DROPLET_REGION="${DROPLET_REGION:-nyc3}" +DROPLET_SIZE="${DROPLET_SIZE:-s-1vcpu-1gb}" +DROPLET_IMAGE="${DROPLET_IMAGE:-ubuntu-24-04-x64}" CREATE_FLAGS=( --no-tailscale --verbose From 845d00b7b5da97cefde335535fd7ee60697f943e Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Thu, 19 Mar 2026 18:16:30 +0100 Subject: [PATCH 06/10] Document prek install -t pre-push for E2E hook setup prek only installs pre-commit hooks by default; the pre-push stage needs an explicit install command. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ce47b31..b1142e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Pre-configured cloud-init, Tailscale VPN (enabled by default), and SSH config ma ```bash uv sync # Install dependencies prek install # Set up pre-commit hooks (one-time) +prek install -t pre-push # Set up pre-push hooks (E2E test) prek run # Run all checks (ruff, ty, shellcheck, etc.) uv run pytest # Run tests uv run dropkit --help # CLI help diff --git a/README.md b/README.md index 7166d61..73b63ab 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ The E2E lifecycle test creates a real droplet, verifies SSH connectivity, and destroys it. It runs automatically as a `pre-push` hook via prek. ```bash +prek install -t pre-push # One-time setup ./tests/e2e/test_lifecycle.sh # Run manually prek run --hook-stage pre-push # Run via prek ``` From b99d8f374279053f8bfb6b85fa87e39c2642572a Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Thu, 19 Mar 2026 19:04:13 +0100 Subject: [PATCH 07/10] Switch E2E hook from pre-push to manual stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-push hooks block the git SSH connection to the remote, which times out and kills the push before the E2E test finishes. Use a manual stage instead — developers run `prek run --stage manual` before pushing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pre-commit-config.yaml | 2 +- CLAUDE.md | 14 +++++++------- README.md | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5a18cb..f702598 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -60,4 +60,4 @@ repos: language: system pass_filenames: false always_run: true - stages: [pre-push] + stages: [manual] diff --git a/CLAUDE.md b/CLAUDE.md index b1142e2..3671c0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,14 @@ Pre-configured cloud-init, Tailscale VPN (enabled by default), and SSH config ma - **Never use `pip`** — always use `uv` for all Python operations - **Always run `prek run`** before committing (or `prek install` to auto-run on commit) - **Keep README.md in sync** when adding commands or features -- **E2E tests run automatically on `pre-push`** via prek — they create a real droplet with hardcoded defaults +- **Run E2E tests before pushing** with `prek run --stage manual` — creates a real droplet with hardcoded defaults ## Quick Commands ```bash uv sync # Install dependencies prek install # Set up pre-commit hooks (one-time) -prek install -t pre-push # Set up pre-push hooks (E2E test) +prek run --stage manual # Run E2E test (before pushing) prek run # Run all checks (ruff, ty, shellcheck, etc.) uv run pytest # Run tests uv run dropkit --help # CLI help @@ -146,13 +146,13 @@ uv run pytest -v # Verbose ### E2E Testing The E2E lifecycle test creates a real droplet, verifies SSH connectivity, -and destroys it. It runs automatically as a **pre-push hook** via prek, -using hardcoded defaults (nyc3, s-1vcpu-1gb, ubuntu-24-04-x64) to avoid -dependence on user config values. +and destroys it. Registered as a prek `manual` stage hook — run before +pushing changes that affect core workflows. Uses hardcoded defaults +(nyc3, s-1vcpu-1gb, ubuntu-24-04-x64) to avoid dependence on user config. ```bash -./tests/e2e/test_lifecycle.sh # Run manually -prek run --hook-stage pre-push # Run via prek +./tests/e2e/test_lifecycle.sh # Run directly +prek run --stage manual # Run via prek ``` Requires a valid dropkit config (`~/.config/dropkit/config.yaml`) with a diff --git a/README.md b/README.md index 73b63ab..68b7c68 100644 --- a/README.md +++ b/README.md @@ -230,12 +230,12 @@ uv run pytest -k "pattern" # Filter by name ### Running E2E Tests The E2E lifecycle test creates a real droplet, verifies SSH connectivity, -and destroys it. It runs automatically as a `pre-push` hook via prek. +and destroys it. Registered as a prek `manual` stage hook — run before +pushing changes that affect core workflows. ```bash -prek install -t pre-push # One-time setup -./tests/e2e/test_lifecycle.sh # Run manually -prek run --hook-stage pre-push # Run via prek +./tests/e2e/test_lifecycle.sh # Run directly +prek run --stage manual # Run via prek ``` Requires a valid dropkit config (`~/.config/dropkit/config.yaml`) with a From 73cd0d96626d17925a4c676539a4eeff1a17c0ab Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Fri, 20 Mar 2026 11:16:53 +0100 Subject: [PATCH 08/10] fix shellcheck issues --- tests/e2e/test_lifecycle.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test_lifecycle.sh b/tests/e2e/test_lifecycle.sh index 4e32153..c8c9329 100755 --- a/tests/e2e/test_lifecycle.sh +++ b/tests/e2e/test_lifecycle.sh @@ -96,10 +96,11 @@ assert_file_not_contains() { } ssh_run() { - # shellcheck disable=SC2086 + # shellcheck disable=SC2086,SC2029 ssh ${SSH_OPTS} "${SSH_HOSTNAME}" "$@" 2>&1 } +# shellcheck disable=SC2329 # invoked via trap cleanup() { if [[ "${DROPLET_CREATED}" == "true" ]]; then echo "" From 0354282c1919c5108cb1705d0e800c3fe490f66b Mon Sep 17 00:00:00 2001 From: Riccardo Schirone <562321+ret2libc@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:47:27 +0100 Subject: [PATCH 09/10] Update tests/e2e/test_lifecycle.sh Co-authored-by: dm --- tests/e2e/test_lifecycle.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/test_lifecycle.sh b/tests/e2e/test_lifecycle.sh index c8c9329..4f56764 100755 --- a/tests/e2e/test_lifecycle.sh +++ b/tests/e2e/test_lifecycle.sh @@ -198,7 +198,7 @@ log "Remote disk:" echo "${df_output}" | while IFS= read -r line; do log " ${line}"; done assert "df reports a filesystem" bash -c "[[ '${df_output}' == */* ]]" -# Cloud-init final status (parse JSON regardless of exit code per CLAUDE.md) +# Cloud-init final status cloud_init_output=$(ssh_run "cloud-init status --format=json" || true) log "Cloud-init status: ${cloud_init_output}" assert "Cloud-init reports done" \ From b5a7b73659d77e368f988f04de20eac232c4ece1 Mon Sep 17 00:00:00 2001 From: Riccardo Schirone Date: Tue, 24 Mar 2026 09:45:02 +0100 Subject: [PATCH 10/10] address comments --- tests/e2e/test_lifecycle.sh | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/e2e/test_lifecycle.sh b/tests/e2e/test_lifecycle.sh index 4f56764..de1ed9a 100755 --- a/tests/e2e/test_lifecycle.sh +++ b/tests/e2e/test_lifecycle.sh @@ -10,9 +10,9 @@ # # Environment variables (all optional): # DROPLET_NAME — Name for the test droplet (default: e2e-) -# DROPLET_REGION — Region slug (default: nyc3) -# DROPLET_SIZE — Size slug (default: s-1vcpu-1gb) -# DROPLET_IMAGE — Image slug (default: ubuntu-24-04-x64) +# DROPLET_REGION — Region slug (default: random from nyc3, sfo3, lon1) +# DROPLET_SIZE — Size slug (default: random from s-1vcpu-1gb, s-2vcpu-4gb) +# DROPLET_IMAGE — Image slug (default: random from ubuntu-24-04-x64, ubuntu-25-04-x64, ubuntu-25-10-x64) # E2E_SSH_TIMEOUT — SSH connect timeout in seconds (default: 10) set -euo pipefail @@ -27,10 +27,19 @@ SSH_CONFIG="${HOME}/.ssh/config" SSH_TIMEOUT="${E2E_SSH_TIMEOUT:-10}" SSH_OPTS="-o StrictHostKeyChecking=accept-new -o ConnectTimeout=${SSH_TIMEOUT} -o BatchMode=yes" -# Hardcoded defaults — tests should not depend on user config -DROPLET_REGION="${DROPLET_REGION:-nyc3}" -DROPLET_SIZE="${DROPLET_SIZE:-s-1vcpu-1gb}" -DROPLET_IMAGE="${DROPLET_IMAGE:-ubuntu-24-04-x64}" +# Randomized defaults — avoid hidden dependencies on specific slugs +_REGIONS=(nyc3 sfo3 lon1) +_SIZES=(s-1vcpu-1gb s-2vcpu-4gb) +_IMAGES=(ubuntu-24-04-x64 ubuntu-25-04-x64 ubuntu-25-10-x64) + +_pick() { + local -n arr=$1 + echo "${arr[RANDOM % ${#arr[@]}]}" +} + +DROPLET_REGION="${DROPLET_REGION:-$(_pick _REGIONS)}" +DROPLET_SIZE="${DROPLET_SIZE:-$(_pick _SIZES)}" +DROPLET_IMAGE="${DROPLET_IMAGE:-$(_pick _IMAGES)}" CREATE_FLAGS=( --no-tailscale --verbose