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 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 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 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