diff --git a/.github/configs/ci-linux-build.json b/.github/configs/ci-linux-build.json new file mode 100644 index 00000000..5ceea642 --- /dev/null +++ b/.github/configs/ci-linux-build.json @@ -0,0 +1,51 @@ +{ + "properties": { + "CC_BINARY": { + "default": "cc" + }, + "LDAPSEARCH_BINARY": { + "default": "ldapsearch" + } + }, + "required_binaries": [ + { + "name": "{CC_BINARY}", + "binproviders": "env,apt", + "overrides": { + "apt": { + "install_args": [ + "build-essential" + ] + } + } + }, + { + "name": "{LDAPSEARCH_BINARY}", + "binproviders": "env,apt", + "overrides": { + "env": { + "version": [ + "ldapsearch", + "-VV" + ] + }, + "apt": { + "install_args": [ + "ldap-utils", + "python3-dev", + "python3-setuptools", + "libssl-dev", + "libldap2-dev", + "libsasl2-dev", + "zlib1g-dev", + "libatomic1" + ], + "version": [ + "ldapsearch", + "-VV" + ] + } + } + } + ] +} diff --git a/.github/configs/ci-linux-env.json b/.github/configs/ci-linux-env.json new file mode 100644 index 00000000..b4aa592a --- /dev/null +++ b/.github/configs/ci-linux-env.json @@ -0,0 +1,28 @@ +{ + "properties": { + "CC_BINARY": { + "default": "cc" + }, + "LDAPSEARCH_BINARY": { + "default": "ldapsearch" + } + }, + "required_binaries": [ + { + "name": "{CC_BINARY}", + "binproviders": "env" + }, + { + "name": "{LDAPSEARCH_BINARY}", + "binproviders": "env", + "overrides": { + "env": { + "version": [ + "ldapsearch", + "-VV" + ] + } + } + } + ] +} diff --git a/.github/configs/ci-macos-brew.json b/.github/configs/ci-macos-brew.json new file mode 100644 index 00000000..bb41dace --- /dev/null +++ b/.github/configs/ci-macos-brew.json @@ -0,0 +1,13 @@ +{ + "properties": { + "BREW_BINARY": { + "default": "brew" + } + }, + "required_binaries": [ + { + "name": "{BREW_BINARY}", + "binproviders": "env" + } + ] +} diff --git a/.github/configs/ci-macos-build.json b/.github/configs/ci-macos-build.json new file mode 100644 index 00000000..fbaf66bb --- /dev/null +++ b/.github/configs/ci-macos-build.json @@ -0,0 +1,44 @@ +{ + "properties": { + "CLANG_BINARY": { + "default": "clang" + }, + "LDAPVC_BINARY": { + "default": "ldapvc" + } + }, + "required_binaries": [ + { + "name": "{CLANG_BINARY}", + "binproviders": "env,brew", + "overrides": { + "brew": { + "install_args": [ + "llvm" + ] + } + } + }, + { + "name": "{LDAPVC_BINARY}", + "binproviders": "env,brew", + "overrides": { + "env": { + "version": [ + "ldapvc", + "-VV" + ] + }, + "brew": { + "install_args": [ + "openldap" + ], + "version": [ + "ldapvc", + "-VV" + ] + } + } + } + ] +} diff --git a/.github/configs/ci-macos-openldap-env.json b/.github/configs/ci-macos-openldap-env.json new file mode 100644 index 00000000..37ff2bae --- /dev/null +++ b/.github/configs/ci-macos-openldap-env.json @@ -0,0 +1,21 @@ +{ + "properties": { + "LDAPVC_BINARY": { + "default": "ldapvc" + } + }, + "required_binaries": [ + { + "name": "{LDAPVC_BINARY}", + "binproviders": "env", + "overrides": { + "env": { + "version": [ + "ldapvc", + "-VV" + ] + } + } + } + ] +} diff --git a/.github/configs/ci-tooling.json b/.github/configs/ci-tooling.json new file mode 100644 index 00000000..ec58ef27 --- /dev/null +++ b/.github/configs/ci-tooling.json @@ -0,0 +1,321 @@ +{ + "properties": { + "CURL_BINARY": { + "default": "curl" + }, + "BASH_BINARY": { + "default": "bash" + }, + "CHMOD_BINARY": { + "default": "chmod" + }, + "DOCKER_BINARY": { + "default": "docker" + }, + "GIT_BINARY": { + "default": "git" + }, + "GREP_BINARY": { + "default": "grep" + }, + "GH_BINARY": { + "default": "gh" + }, + "EXPORTFS_BINARY": { + "default": "exportfs" + }, + "JQ_BINARY": { + "default": "jq" + }, + "MKDIR_BINARY": { + "default": "mkdir" + }, + "MOUNT_CIFS_BINARY": { + "default": "mount.cifs" + }, + "MOUNT_NFS_BINARY": { + "default": "mount.nfs" + }, + "NODE_BINARY": { + "default": "node" + }, + "PS_BINARY": { + "default": "ps" + }, + "PYTHON_BINARY": { + "default": "python" + }, + "PV_BINARY": { + "default": "pv" + }, + "SSH_BINARY": { + "default": "ssh" + }, + "SED_BINARY": { + "default": "sed" + }, + "SMBD_BINARY": { + "default": "smbd" + }, + "SUDO_BINARY": { + "default": "sudo" + }, + "SYSTEMCTL_BINARY": { + "default": "systemctl" + }, + "TAR_BINARY": { + "default": "tar" + }, + "TREE_BINARY": { + "default": "tree" + }, + "TR_BINARY": { + "default": "tr" + }, + "RM_BINARY": { + "default": "rm" + }, + "UMOUNT_BINARY": { + "default": "umount" + }, + "UV_BINARY": { + "default": "uv" + }, + "UNAME_BINARY": { + "default": "uname" + } + }, + "ci_binaries": [ + { + "name": "{PYTHON_BINARY}", + "binproviders": "env" + }, + { + "name": "{JQ_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{UV_BINARY}", + "binproviders": "env" + } + ], + "docker_binaries": [ + { + "name": "{DOCKER_BINARY}", + "binproviders": "env" + }, + { + "name": "{GIT_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{PYTHON_BINARY}", + "binproviders": "env" + }, + { + "name": "{UNAME_BINARY}", + "binproviders": "env" + } + ], + "deploy_binaries": [ + { + "name": "{GIT_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{PYTHON_BINARY}", + "binproviders": "env" + }, + { + "name": "{SSH_BINARY}", + "binproviders": "env" + } + ], + "docker_debug_binaries": [ + { + "name": "{DOCKER_BINARY}", + "binproviders": "env" + }, + { + "name": "{PV_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{TAR_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{TREE_BINARY}", + "binproviders": "env,apt,brew" + } + ], + "docker_validation_binaries": [ + { + "name": "{DOCKER_BINARY}", + "binproviders": "env" + }, + { + "name": "{GREP_BINARY}", + "binproviders": "env" + }, + { + "name": "{MKDIR_BINARY}", + "binproviders": "env" + }, + { + "name": "{RM_BINARY}", + "binproviders": "env" + }, + { + "name": "{SED_BINARY}", + "binproviders": "env" + }, + { + "name": "{SSH_BINARY}", + "binproviders": "env" + }, + { + "name": "{TR_BINARY}", + "binproviders": "env" + }, + { + "name": "{UNAME_BINARY}", + "binproviders": "env" + } + ], + "mount_validation_binaries": [ + { + "name": "{BASH_BINARY}", + "binproviders": "env" + }, + { + "name": "{CHMOD_BINARY}", + "binproviders": "env" + }, + { + "name": "{EXPORTFS_BINARY}", + "binproviders": "env,apt", + "overrides": { + "apt": { + "install_args": [ + "nfs-kernel-server" + ] + } + } + }, + { + "name": "{MKDIR_BINARY}", + "binproviders": "env" + }, + { + "name": "{MOUNT_CIFS_BINARY}", + "binproviders": "env,apt", + "overrides": { + "apt": { + "install_args": [ + "cifs-utils" + ] + } + } + }, + { + "name": "{MOUNT_NFS_BINARY}", + "binproviders": "env,apt", + "overrides": { + "apt": { + "install_args": [ + "nfs-common" + ] + } + } + }, + { + "name": "{SMBD_BINARY}", + "binproviders": "env,apt", + "overrides": { + "apt": { + "install_args": [ + "samba" + ] + } + } + }, + { + "name": "{SUDO_BINARY}", + "binproviders": "env" + }, + { + "name": "{SYSTEMCTL_BINARY}", + "binproviders": "env" + }, + { + "name": "{UMOUNT_BINARY}", + "binproviders": "env" + } + ], + "process_binaries": [ + { + "name": "{PS_BINARY}", + "binproviders": "env" + } + ], + "docs_binaries": [ + { + "name": "{CURL_BINARY}", + "binproviders": "env,apt,brew" + } + ], + "git_binaries": [ + { + "name": "{GIT_BINARY}", + "binproviders": "env,apt,brew" + } + ], + "github_binaries": [ + { + "name": "{GH_BINARY}", + "binproviders": "env,apt,brew" + } + ], + "node_binaries": [ + { + "name": "{NODE_BINARY}", + "binproviders": "env,npm", + "min_version": "22.23.1", + "overrides": { + "npm": { + "install_args": [ + "node@22.23.1" + ], + "postinstall_scripts": true + } + } + } + ], + "release_binaries": [ + { + "name": "{UV_BINARY}", + "binproviders": "env,pip,brew" + }, + { + "name": "{PYTHON_BINARY}", + "binproviders": "env" + }, + { + "name": "{GH_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{GIT_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{JQ_BINARY}", + "binproviders": "env,apt,brew" + }, + { + "name": "{CURL_BINARY}", + "binproviders": "env,apt,brew" + } + ] +} diff --git a/.github/scripts/clone_abx_repo.sh b/.github/scripts/clone_abx_repo.sh index 01908e03..ea4fa2c1 100755 --- a/.github/scripts/clone_abx_repo.sh +++ b/.github/scripts/clone_abx_repo.sh @@ -3,25 +3,44 @@ set -Eeuo pipefail repo_name="$1" target_dir="${2:-$repo_name}" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +lock_file="$repo_root/uv.lock" +tooling_config="$repo_root/.github/configs/ci-tooling.json" -version="$( -python3 - "$repo_name" <<'PY' -import re -import sys -from pathlib import Path +locked_version() { + local wanted="$1" line package="" + while IFS= read -r line; do + case "$line" in + '[[package]]') package="" ;; + "name = \"${wanted}\"") package="$wanted" ;; + 'version = "'*'"') + if [[ "$package" == "$wanted" ]]; then + line="${line#version = \"}" + printf '%s\n' "${line%\"}" + return 0 + fi + ;; + esac + done < "$lock_file" + return 1 +} -repo_name = sys.argv[1] -lock_text = Path("uv.lock").read_text() -match = re.search( - rf'^\[\[package\]\]\s*\nname = "{re.escape(repo_name)}"\s*\nversion = "([^"]+)"', - lock_text, - re.MULTILINE, -) -if not match: - raise SystemExit(f"Could not find {repo_name} in uv.lock") -print(match.group(1)) -PY -)" +version="$(locked_version "$repo_name")" +[[ -n "$version" ]] || { echo "Could not find ${repo_name} in uv.lock" >&2; exit 1; } + +abxpkg_version="$(locked_version abxpkg)" +[[ -n "$abxpkg_version" ]] || { echo "Could not find abxpkg in uv.lock" >&2; exit 1; } + +ABXPKG_LIB_DIR="${ABXPKG_LIB_DIR:-${RUNNER_TEMP:-/tmp}/archivebox-clone-abxpkg}" +mkdir -p "$ABXPKG_LIB_DIR/env/bin" +uv run --no-project --with "abxpkg==$abxpkg_version" abxpkg env \ + --install \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$tooling_config:git_binaries" \ + >/dev/null +git_binary="$ABXPKG_LIB_DIR/env/bin/git" +[[ -L "$git_binary" ]] +[[ -x "$git_binary" ]] echo "Cloning ArchiveBox/${repo_name}@v${version} into ${target_dir}" -git clone --depth=1 --branch "v${version}" "https://github.com/ArchiveBox/${repo_name}.git" "$target_dir" +"$git_binary" clone --depth=1 --branch "v${version}" "https://github.com/ArchiveBox/${repo_name}.git" "$target_dir" diff --git a/.github/scripts/discover_test_matrix.py b/.github/scripts/discover_test_matrix.py new file mode 100755 index 00000000..f2c2f03b --- /dev/null +++ b/.github/scripts/discover_test_matrix.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Build deterministic GitHub Actions matrices from every discovered test file.""" + +import argparse +import json +import re +from pathlib import Path + + +CHROMIUM_PATTERN = re.compile( + rb"chrom|archivewebpage|PLUGINS=.*title|--plugins=.*title|SAVE_TITLE.*[Tt]rue", + re.IGNORECASE, +) +SONIC_PATTERN = re.compile( + rb"""shutil\.which\(["']sonic|SEARCH_BACKEND_ENGINE=.*sonic|worker_sonic""", + re.IGNORECASE, +) + + +def contains(pattern: re.Pattern[bytes], paths: list[Path]) -> bool: + return any(pattern.search(path.read_bytes()) for path in paths) + + +def archivebox_matrix(root: Path) -> list[dict[str, object]]: + tests = sorted((root / "archivebox/tests").glob("test_*.py")) + if not tests: + raise SystemExit("No ArchiveBox tests discovered") + + shard_count = min(16, len(tests)) + matrix = [] + assigned: list[Path] = [] + for shard in range(shard_count): + shard_tests = tests[shard::shard_count] + assigned.extend(shard_tests) + matrix.append( + { + "name": f"main/shard-{shard + 1}", + "paths": [path.relative_to(root).as_posix() for path in shard_tests], + "needs_chromium": contains(CHROMIUM_PATTERN, shard_tests), + "needs_sonic": contains(SONIC_PATTERN, shard_tests), + }, + ) + + if sorted(assigned) != tests or len(assigned) != len(set(assigned)): + raise SystemExit("ArchiveBox tests were not assigned exactly once") + print(f"Assigned {len(tests)} test files exactly once across {shard_count} shards") + return matrix + + +def plugin_matrix(root: Path) -> list[dict[str, object]]: + plugins_root = root / "abx-plugins/abx_plugins/plugins" + suite_dirs = sorted(path for path in plugins_root.glob("*/tests") if path.is_dir()) + root_tests = sorted((root / "abx-plugins/tests").glob("test_*.py")) + if not suite_dirs or not root_tests: + raise SystemExit("Plugin suites or root tests were not discovered") + + matrix: list[dict[str, object]] = [] + expected = list(root_tests) + for suite_dir in suite_dirs: + suite_tests = sorted(suite_dir.rglob("test_*.py")) + if not suite_tests: + raise SystemExit(f"No tests found in {suite_dir}") + expected.extend(suite_tests) + plugin = suite_dir.parent.name + matrix.append( + { + "plugin": plugin, + "name": f"plugin/{plugin}", + "test_path": suite_dir.relative_to(root).as_posix(), + "config_path": (suite_dir.parent / "config.json").relative_to(root).as_posix(), + "needs_chromium": contains(re.compile(rb"chrom", re.IGNORECASE), suite_tests), + "needs_sonic": plugin == "search_backend_sonic", + }, + ) + + matrix.append( + { + "plugin": "root", + "name": "plugin/root", + "test_path": "abx-plugins/tests", + "config_path": "abx-plugins/abx_plugins/plugins/base/config.json", + "needs_chromium": contains(re.compile(rb"chrom|archivewebpage", re.IGNORECASE), root_tests), + "needs_sonic": False, + }, + ) + + assigned = [] + for entry in matrix: + assigned.extend(sorted((root / str(entry["test_path"])).rglob("test_*.py"))) + if sorted(assigned) != sorted(expected) or len(assigned) != len(set(assigned)): + raise SystemExit("Plugin tests were not assigned exactly once") + print( + f"Assigned {len(suite_dirs)} plugin suites and {len(root_tests)} root test files exactly once", + ) + return matrix + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("scope", choices=("archivebox", "plugins")) + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + args = parser.parse_args() + root = args.workspace.resolve() + matrix = archivebox_matrix(root) if args.scope == "archivebox" else plugin_matrix(root) + print(json.dumps(matrix, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/docs_http_server.py b/.github/scripts/docs_http_server.py new file mode 100644 index 00000000..2f7029f6 --- /dev/null +++ b/.github/scripts/docs_http_server.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + + +class DocsRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + body = f"ArchiveBox docs fixture

{self.path}

\n".encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + return + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--ready-fifo", type=Path, required=True) + args = parser.parse_args() + + with ThreadingHTTPServer(("127.0.0.1", 0), DocsRequestHandler) as server: + host, port = server.server_address + with args.ready_fifo.open("w") as ready_fifo: + ready_fifo.write(f"http://{host}:{port}\n") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..7a52c138 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + pull_request: + branches: [dev] + push: + branches: [dev] + workflow_dispatch: + +permissions: + actions: read + contents: read + packages: write + security-events: write + +concurrency: + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +jobs: + lint: + name: Linters + uses: ./.github/workflows/lint.yml + secrets: inherit + + complete-tests: + name: Complete test suite + uses: ./.github/workflows/test.yml + secrets: inherit + + sharded-plugin-tests: + name: Sharded and plugin tests + uses: ./.github/workflows/test-parallel.yml + secrets: inherit + + documentation: + name: Documentation and root tests + uses: ./.github/workflows/docs.yml + secrets: inherit + + codeql: + name: CodeQL + uses: ./.github/workflows/codeql.yml + secrets: inherit + + python-artifacts: + name: Tested Python artifacts + uses: ./.github/workflows/pip.yml + secrets: inherit + + docker-digests: + name: Tested Docker images + uses: ./.github/workflows/docker.yml + with: + push_digests: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }} + secrets: inherit + + required: + name: All required CI lanes + needs: + - lint + - complete-tests + - sharded-plugin-tests + - documentation + - codeql + - python-artifacts + - docker-digests + runs-on: ubuntu-24.04 + steps: + - run: echo 'All required CI lanes and tested release artifacts succeeded.' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 2feee2e3..447aec19 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -17,33 +17,24 @@ jobs: (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write issues: write id-token: write - actions: read # Required for Claude to read CI results on PRs + actions: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 1 - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 78bff057..1b683f84 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,46 +1,17 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] + workflow_call: schedule: - cron: '33 17 * * 6' -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + runs-on: ubuntu-24.04 permissions: - # required for all workflows security-events: write - - # required to fetch internal or private CodeQL packs packages: read - - # only required for workflows in private repositories actions: read contents: read @@ -50,47 +21,18 @@ jobs: include: - language: python build-mode: none - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + - language: javascript-typescript + build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@b7351df727350dca84cb9d725d57dcf5bc82ba26 # v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@b7351df727350dca84cb9d725d57dcf5bc82ba26 # v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy-publicsite.yml b/.github/workflows/deploy-publicsite.yml index daaf5a21..c42ded47 100644 --- a/.github/workflows/deploy-publicsite.yml +++ b/.github/workflows/deploy-publicsite.yml @@ -15,29 +15,29 @@ permissions: id-token: write concurrency: - group: github-pages-publicsite - cancel-in-progress: true + group: ${{ github.event_name == 'workflow_dispatch' && format('github-pages-publicsite-manual-{0}', github.run_id) || 'github-pages-publicsite' }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 1 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: ./publicsite - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e8fdef2a..a30a55e2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,24 +1,23 @@ name: Build Docker image on: - workflow_dispatch: - push: - branches: - - '**' - # pull_request: + workflow_call: + inputs: + push_digests: + description: Push validated per-platform images by digest for the release workflow + required: false + default: false + type: boolean env: DOCKERHUB_IMAGE: archivebox/archivebox GHCR_IMAGE: ghcr.io/archivebox/archivebox permissions: + actions: read contents: read packages: write -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: build: name: build ${{ matrix.platform }} @@ -31,22 +30,78 @@ jobs: runner: ubuntu-24.04 cache_scope: docker-amd64 artifact_name: digest-linux-amd64 + local_tag: archivebox-ci:amd64 - platform: linux/arm64 runner: ubuntu-24.04-arm cache_scope: docker-arm64 artifact_name: digest-linux-arm64 + local_tag: archivebox-ci:arm64 steps: - name: Checkout - uses: actions/checkout@v4 - # with: - # submodules: true - # fetch-depth: 1 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + + - name: Resolve Docker through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python - <<'PY' + import tomllib + from pathlib import Path + + packages = tomllib.loads(Path("uv.lock").read_text())["package"] + matches = [package["version"] for package in packages if package["name"] == "abxpkg"] + if len(matches) != 1: + raise SystemExit(f"Expected one locked abxpkg package, found {len(matches)}") + print(matches[0]) + PY + )" + test -n "$ABXPKG_VERSION" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH" + + docker_env="$( + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" + )" + JQ_BINARY="$ABXPKG_LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$docker_env" >> "$GITHUB_ENV" + + docker_binary="$ABXPKG_LIB_DIR/env/bin/docker" + UV_BINARY="$ABXPKG_LIB_DIR/env/bin/uv" + { + echo "DOCKER_BINARY=$docker_binary" + echo "JQ_BINARY=$JQ_BINARY" + echo "UV_BINARY=$UV_BINARY" + } >> "$GITHUB_ENV" + for binary in "$docker_binary" "$JQ_BINARY" "$UV_BINARY"; do + test -L "$binary" + test -x "$binary" + done + "$docker_binary" --version - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 with: - version: latest install: true - name: Builder instance name @@ -60,35 +115,45 @@ jobs: shell: bash run: | set -Eeuo pipefail - ABX_DL_VERSION="$(python3 - <<'PY' - import re + ABX_DL_VERSION="$("$UV_BINARY" run --no-project python - <<'PY' import tomllib + from pathlib import Path - with open("pyproject.toml", "rb") as f: - deps = tomllib.load(f)["project"]["dependencies"] - - for dep in deps: - match = re.match(r"abx-dl\s*(?:==|>=)\s*([^,;\s]+)", dep) - if match: - print(match.group(1)) - break - else: - raise SystemExit("Missing abx-dl dependency in pyproject.toml") + packages = tomllib.loads(Path("uv.lock").read_text())["package"] + matches = [package["version"] for package in packages if package["name"] == "abx-dl"] + if len(matches) != 1: + raise SystemExit(f"Expected one locked abx-dl package, found {len(matches)}") + print(matches[0]) PY )" - ABX_DL_IMAGE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}" + test -n "$ABX_DL_VERSION" + ABX_DL_REFERENCE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}" + INSPECT_OUTPUT="$("$DOCKER_BINARY" buildx imagetools inspect "$ABX_DL_REFERENCE")" + printf '%s\n' "$INSPECT_OUTPUT" + ABX_DL_DIGEST="$(INSPECT_OUTPUT="$INSPECT_OUTPUT" "$UV_BINARY" run --no-project python - <<'PY' + import os + + digest = next( + (fields[1] for line in os.environ["INSPECT_OUTPUT"].splitlines() if (fields := line.split()) and fields[0] == "Digest:"), + "", + ) + print(digest) + PY + )" + [[ "$ABX_DL_DIGEST" == sha256:* ]] + ABX_DL_IMAGE="${ABX_DL_REFERENCE%%@*}@${ABX_DL_DIGEST}" echo "image=${ABX_DL_IMAGE}" >> "$GITHUB_OUTPUT" - docker buildx imagetools inspect "${ABX_DL_IMAGE}" - name: Login to Docker Hub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: inputs.push_digests with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: inputs.push_digests with: registry: ghcr.io username: ${{ github.actor }} @@ -99,13 +164,14 @@ jobs: shell: bash run: | set -Eeuo pipefail - VERSION="$(python3 - <<'PY' + VERSION="$("$UV_BINARY" run --no-project python - <<'PY' import tomllib + from pathlib import Path - with open("pyproject.toml", "rb") as f: - print(tomllib.load(f)["project"]["version"]) + print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]) PY )" + test -n "$VERSION" { echo 'labels< /tmp/archivebox-image-version.txt - grep -F "ArchiveBox v${{ steps.docker_meta.outputs.version }}" /tmp/archivebox-version.out - grep -F "COMMIT_HASH=${SHORT_SHA}" /tmp/archivebox-version.out - grep -Fx "COMMIT_HASH=${GITHUB_SHA}" /tmp/archivebox-image-version.txt - - python3 - "$IMAGE" <<'PY' - import json - import subprocess + "$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init + ARCHIVEBOX_VERSION_OUTPUT="$("$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox version)" + printf '%s\n' "$ARCHIVEBOX_VERSION_OUTPUT" + "$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox status + IMAGE_VERSION_OUTPUT="$("$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' --entrypoint cat "$IMAGE" /VERSION.txt)" + ARCHIVEBOX_VERSION_OUTPUT="$ARCHIVEBOX_VERSION_OUTPUT" IMAGE_VERSION_OUTPUT="$IMAGE_VERSION_OUTPUT" \ + "$UV_BINARY" run --no-project python - \ + "ArchiveBox v${{ steps.docker_meta.outputs.version }}" \ + "COMMIT_HASH=${SHORT_SHA}" \ + "COMMIT_HASH=${GITHUB_SHA}" <<'PY' + import os import sys - image = sys.argv[1] - limit = 780 * 1024 * 1024 - manifest = json.loads(subprocess.check_output(["docker", "manifest", "inspect", image])) - total = manifest.get("config", {}).get("size", 0) + sum(layer.get("size", 0) for layer in manifest.get("layers", [])) - print(f"{image} compressed_size={total / 1024 / 1024:.2f} MiB limit={limit / 1024 / 1024:.2f} MiB") - if total > limit: - raise SystemExit(f"{image} is over the compressed size limit") + version_output = os.environ["ARCHIVEBOX_VERSION_OUTPUT"] + image_version_lines = os.environ["IMAGE_VERSION_OUTPUT"].splitlines() + if sys.argv[1] not in version_output: + raise SystemExit(f"Missing version marker: {sys.argv[1]}") + if sys.argv[2] not in version_output: + raise SystemExit(f"Missing short commit marker: {sys.argv[2]}") + if sys.argv[3] not in image_version_lines: + raise SystemExit(f"Missing exact commit marker: {sys.argv[3]}") PY + - name: Validate compressed image size + if: inputs.push_digests + shell: bash + run: | + set -Eeuo pipefail + IMAGE="${DOCKERHUB_IMAGE}@${{ steps.docker_build.outputs.digest }}" + LIMIT=$((780 * 1024 * 1024)) + TOTAL="$("$DOCKER_BINARY" manifest inspect "$IMAGE" | "$JQ_BINARY" '[.config.size, (.layers[]?.size)] | add')" + printf '%s compressed_size=%s MiB limit=%s MiB\n' \ + "$IMAGE" "$((TOTAL / 1024 / 1024))" "$((LIMIT / 1024 / 1024))" + [[ "$TOTAL" -le "$LIMIT" ]] || { echo "$IMAGE is over the compressed size limit" >&2; exit 1; } + - name: Export digest + if: inputs.push_digests shell: bash run: | set -Eeuo pipefail @@ -182,151 +285,10 @@ jobs: touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + if: inputs.push_digests + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ matrix.artifact_name }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 - - publish: - name: publish multiarch tags - runs-on: ubuntu-24.04 - needs: - - build - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - version: latest - install: true - - - name: Login to Docker Hub - uses: docker/login-action@v3 - if: github.event_name != 'pull_request' - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digest-* - merge-multiple: true - - - name: Collect Docker tags - id: docker_meta - shell: bash - run: | - set -Eeuo pipefail - VERSION="$(python3 - <<'PY' - import tomllib - - with open("pyproject.toml", "rb") as f: - print(tomllib.load(f)["project"]["version"]) - PY - )" - BRANCH_TAG="$(printf '%s' "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9_.-' '-' | sed -E 's/^-+//; s/-+$//; s/-+/-/g' | cut -c1-128)" - SHORT_SHA="${GITHUB_SHA::12}" - test -n "$BRANCH_TAG" - test -n "$SHORT_SHA" - - { - echo 'dockerhub_tags<> "$GITHUB_OUTPUT" - - echo "[+] Publishing Docker Hub tags:" - if [[ "${GITHUB_REF_NAME}" == "main" ]]; then - printf '%s\n' "${DOCKERHUB_IMAGE}:latest" - fi - printf '%s\n' "${DOCKERHUB_IMAGE}:${BRANCH_TAG}" "${DOCKERHUB_IMAGE}:${VERSION}" "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}" - echo "[+] Publishing GHCR tags:" - if [[ "${GITHUB_REF_NAME}" == "main" ]]; then - printf '%s\n' "${GHCR_IMAGE}:latest" - fi - printf '%s\n' "${GHCR_IMAGE}:${BRANCH_TAG}" "${GHCR_IMAGE}:${VERSION}" "${GHCR_IMAGE}:sha-${SHORT_SHA}" - - - name: Create Docker Hub manifest - shell: bash - run: | - set -Eeuo pipefail - mapfile -t DIGESTS < <(find /tmp/digests -maxdepth 1 -type f -printf '%f\n' | sort) - [[ "${#DIGESTS[@]}" -gt 0 ]] - - TAG_ARGS=() - while IFS= read -r tag; do - [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag") - done <<< "${{ steps.docker_meta.outputs.dockerhub_tags }}" - - REFS=() - for digest in "${DIGESTS[@]}"; do - REFS+=("${DOCKERHUB_IMAGE}@sha256:${digest}") - done - - docker buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}" - - - name: Create GHCR manifest - shell: bash - run: | - set -Eeuo pipefail - mapfile -t DIGESTS < <(find /tmp/digests -maxdepth 1 -type f -printf '%f\n' | sort) - [[ "${#DIGESTS[@]}" -gt 0 ]] - - TAG_ARGS=() - while IFS= read -r tag; do - [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag") - done <<< "${{ steps.docker_meta.outputs.ghcr_tags }}" - - REFS=() - for digest in "${DIGESTS[@]}"; do - REFS+=("${GHCR_IMAGE}@sha256:${digest}") - done - - docker buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}" - - - name: Inspect published images - shell: bash - run: | - set -Eeuo pipefail - while IFS= read -r tag; do - [[ -n "$tag" ]] && docker buildx imagetools inspect "$tag" - done <<< "${{ steps.docker_meta.outputs.dockerhub_tags }}" - while IFS= read -r tag; do - [[ -n "$tag" ]] && docker buildx imagetools inspect "$tag" - done <<< "${{ steps.docker_meta.outputs.ghcr_tags }}" - - - name: Update README - uses: peter-evans/dockerhub-description@v4 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - repository: archivebox/archivebox diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..7c24d8a0 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,289 @@ +name: Documentation tests + +on: + workflow_call: + +env: + PYTHONIOENCODING: utf-8 + USE_COLOR: "False" + SHOW_PROGRESS: "False" + ARCHIVEBOX_PUBLISH_ADMIN_PASSWORD: "archivebox-docs-ci-only" + +jobs: + docs-matrix: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + - id: matrix + run: | + uv run --no-project python - <<'PY' >> "$GITHUB_OUTPUT" + import json + import tomllib + + with open("docs/codeblocks.toml", "rb") as manifest_file: + ci = tomllib.load(manifest_file)["ci"] + + include = [] + for environment, runner in ci["standard"].items(): + if environment == "core": + include.extend( + { + "environment": environment, + "runner": runner, + "core_shard": shard, + "job_name": f"core/{shard}", + "validate_manifest": shard == "metadata", + } + for shard in ci["core_shards"] + ) + else: + include.append( + { + "environment": environment, + "runner": runner, + "core_shard": "", + "job_name": environment, + "validate_manifest": False, + }, + ) + + print("matrix=" + json.dumps({"include": include}, separators=(",", ":"))) + PY + + docs-standard: + name: docs/${{ matrix.job_name }} + needs: docs-matrix + runs-on: ${{ matrix.runner }} + timeout-minutes: 5 + env: + DOCS_CORE_SHARD: ${{ matrix.core_shard }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.docs-matrix.outputs.matrix) }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: true + fetch-depth: 1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + - name: Install ArchiveBox + run: uv sync --dev --all-extras + - name: Prepare abxpkg environment + shell: bash + run: | + { + echo "ABXPKG_LIB_DIR=${{ runner.temp }}/archivebox-docs-lib" + echo "ARCHIVEBOX_PROJECT_DIR=$GITHUB_WORKSPACE" + } >> "$GITHUB_ENV" + echo "${{ runner.temp }}/archivebox-docs-lib/env/bin" >> "$GITHUB_PATH" + - name: Resolve Node.js through abxpkg + shell: bash + run: | + set -Eeuo pipefail + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH" + tooling_env="$( + uv run --no-sync abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:node_binaries" + )" + JQ_BINARY="$ABXPKG_LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$tooling_env" >> "$GITHUB_ENV" + + node_binary="$ABXPKG_LIB_DIR/env/bin/node" + { + echo "JQ_BINARY=$JQ_BINARY" + echo "NODE_BINARY=$node_binary" + } >> "$GITHUB_ENV" + test -L "$node_binary" + test -x "$node_binary" + "$node_binary" --version + - name: Resolve documentation shell tools through abxpkg + shell: bash + run: | + set -Eeuo pipefail + shell_env="$( + uv run --no-sync abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries" + )" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$shell_env" >> "$GITHUB_ENV" + + curl_binary="$ABXPKG_LIB_DIR/env/bin/curl" + echo "CURL_BINARY=$curl_binary" >> "$GITHUB_ENV" + test -L "$curl_binary" + test -x "$curl_binary" + - name: Resolve Docker through abxpkg + if: matrix.environment == 'docker' + shell: bash + run: | + set -Eeuo pipefail + docker_env="$( + uv run --no-sync abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" + )" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$docker_env" >> "$GITHUB_ENV" + + docker_binary="$ABXPKG_LIB_DIR/env/bin/docker" + echo "DOCKER_BINARY=$docker_binary" >> "$GITHUB_ENV" + test -L "$docker_binary" + test -x "$docker_binary" + "$docker_binary" --version + - name: Start bound local documentation site + shell: bash + run: | + ready_fifo="${{ runner.temp }}/archivebox-docs-http-ready" + mkfifo "$ready_fifo" + uv run --no-sync python .github/scripts/docs_http_server.py --ready-fifo "$ready_fifo" & + server_pid=$! + IFS= read -r docs_url < "$ready_fifo" + { + echo "ARCHIVEBOX_DOCS_SERVER_PID=$server_pid" + echo "ARCHIVEBOX_DOCS_URL_ONE=$docs_url/collection-one" + echo "ARCHIVEBOX_DOCS_URL_TWO=$docs_url/collection-two" + echo "ARCHIVEBOX_DOCS_ARCHIVEBOX_PORT=18000" + echo "ARCHIVEBOX_DOCS_STATIC_PORT=18001" + } >> "$GITHUB_ENV" + - name: Resolve documentation validators through abxpkg + if: matrix.validate_manifest + run: | + validators_env="$( + uv run abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/docs/mermaid-binary.json:required_binaries" \ + --deps-from="$GITHUB_WORKSPACE/docs/nginx-binary.json:required_binaries" + )" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$validators_env" >> "$GITHUB_ENV" + - name: Resolve merge tools through abxpkg + if: matrix.environment == 'merge' + run: | + uv run abxpkg install rsync --lib "$ABXPKG_LIB_DIR" --binproviders env,apt,brew + uv run abxpkg install sqlite3 --lib "$ABXPKG_LIB_DIR" --binproviders env,apt,brew + - name: Validate documentation inventory and structured fences + if: matrix.validate_manifest + run: uv run --no-sync pytest -q docs/test_codeblocks_manifest.py + - name: Initialize documentation collection + if: contains(fromJSON('["core", "macos", "root"]'), matrix.environment) + shell: bash + run: | + docs_data_dir="${{ runner.temp }}/archivebox-docs-data" + mkdir -p "$docs_data_dir" + (cd "$docs_data_dir" && uv run --project "$GITHUB_WORKSPACE" --no-sync archivebox init) + echo "ARCHIVEBOX_DOCS_DATA_DIR=$docs_data_dir" >> "$GITHUB_ENV" + - name: Build local documentation image + if: matrix.environment == 'docker' + run: '"$DOCKER_BINARY" build --tag archivebox-docs-ci .' + - name: Run documentation code blocks + if: matrix.environment != 'root' && !contains(fromJSON('["core", "macos"]'), matrix.environment) + run: | + mapfile -t docs_paths < <( + uv run --no-sync python - <<'PY' + from pathlib import Path + + print(*(str(path) for path in sorted(Path("docs").rglob("*.md")) if not path.is_symlink()), sep="\n") + PY + ) + uv run --no-sync pytest -vv --tb=long README.md AGENTS.md skills "${docs_paths[@]}" --docs-environment=${{ matrix.environment }} + - name: Run collection documentation code blocks + if: contains(fromJSON('["core", "macos"]'), matrix.environment) + working-directory: ${{ env.ARCHIVEBOX_DOCS_DATA_DIR }} + run: | + docs_paths=() + if [[ -n "$DOCS_CORE_SHARD" ]]; then + mapfile -t docs_paths < <( + uv run --project "$GITHUB_WORKSPACE" --no-sync python - "$DOCS_CORE_SHARD" <<'PY' + import os + from pathlib import Path + import sys + import tomllib + + workspace = Path(os.environ["GITHUB_WORKSPACE"]) + with (workspace / "docs" / "codeblocks.toml").open("rb") as manifest_file: + shard_paths = tomllib.load(manifest_file)["ci"]["core_shards"][sys.argv[1]] + print(*(workspace / path for path in shard_paths), sep="\n") + PY + ) + else + while IFS= read -r docs_path; do + docs_paths+=("$docs_path") + done < <( + uv run --project "$GITHUB_WORKSPACE" --no-sync python - <<'PY' + import os + from pathlib import Path + + docs_dir = Path(os.environ["GITHUB_WORKSPACE"]) / "docs" + print(*(str(path) for path in sorted(docs_dir.rglob("*.md")) if not path.is_symlink()), sep="\n") + PY + ) + docs_paths=("$GITHUB_WORKSPACE/README.md" "$GITHUB_WORKSPACE/AGENTS.md" "$GITHUB_WORKSPACE/skills" "${docs_paths[@]}") + fi + uv run --project "$GITHUB_WORKSPACE" --no-sync pytest -vv --tb=long "${docs_paths[@]}" --docs-environment=${{ matrix.environment }} + - name: Run root documentation code blocks + if: matrix.environment == 'root' + working-directory: ${{ env.ARCHIVEBOX_DOCS_DATA_DIR }} + run: | + uv_bin="$ABXPKG_LIB_DIR/env/bin/uv" + test -L "$uv_bin" + test -x "$uv_bin" + mapfile -t docs_paths < <( + uv run --project "$GITHUB_WORKSPACE" --no-sync python - <<'PY' + import os + from pathlib import Path + + docs_dir = Path(os.environ["GITHUB_WORKSPACE"]) / "docs" + print(*(str(path) for path in sorted(docs_dir.rglob("*.md")) if not path.is_symlink()), sep="\n") + PY + ) + sudo --preserve-env=PATH,ABXPKG_LIB_DIR,ARCHIVEBOX_DOCS_URL_ONE,ARCHIVEBOX_DOCS_URL_TWO,ARCHIVEBOX_DOCS_ARCHIVEBOX_PORT,ARCHIVEBOX_DOCS_STATIC_PORT,ARCHIVEBOX_PUBLISH_ADMIN_PASSWORD "$uv_bin" run --project "$GITHUB_WORKSPACE" --no-sync pytest -vv --tb=long "$GITHUB_WORKSPACE/README.md" "$GITHUB_WORKSPACE/AGENTS.md" "$GITHUB_WORKSPACE/skills" "${docs_paths[@]}" --docs-environment=${{ matrix.environment }} + - name: Stop local documentation site + if: always() + run: kill "$ARCHIVEBOX_DOCS_SERVER_PID" + + docs-freebsd: + name: docs/freebsd + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: vmactions/freebsd-vm@77ed28d336d03fe19a3f4f7266c1d2c4714dd79d # v1.5.2 + with: + usesh: true + prepare: pkg install -y py313-uv + run: uv run --no-project --with pytest --with pytest-codeblocks pytest -o addopts=--codeblocks -vv --tb=long README.md AGENTS.md skills $(uv run --no-project python -c 'from pathlib import Path; print(*(str(path) for path in sorted(Path("docs").rglob("*.md")) if not path.is_symlink()))') --docs-environment=freebsd + + docs-openbsd: + name: docs/openbsd + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: vmactions/openbsd-vm@c941015845c0f0c429676840963dc63b226d4f69 # v1.4.5 + with: + usesh: true + prepare: pkg_add uv + run: uv run --no-project --with pytest --with pytest-codeblocks pytest -o addopts=--codeblocks -vv --tb=long README.md AGENTS.md skills $(uv run --no-project python -c 'from pathlib import Path; print(*(str(path) for path in sorted(Path("docs").rglob("*.md")) if not path.is_symlink()))') --docs-environment=openbsd diff --git a/.github/workflows/duplicate-issue-detection.yml b/.github/workflows/duplicate-issue-detection.yml index 98dcd839..4d1df6c3 100644 --- a/.github/workflows/duplicate-issue-detection.yml +++ b/.github/workflows/duplicate-issue-detection.yml @@ -6,18 +6,55 @@ on: jobs: check-duplicates: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read issues: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 1 - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + + - name: Resolve OpenCode and workflow tools through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + uv sync --locked --no-sources --no-install-project + OPENCODE_CONFIG="$(uv run --no-sync --no-sources python -c 'from abx_plugins import get_plugins_dir; print(get_plugins_dir() / "opencode" / "config.json")')" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH" + opencode_env="$( + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:github_binaries" \ + --deps-from="$OPENCODE_CONFIG:required_binaries" + )" + jq_binary="$ABXPKG_LIB_DIR/env/bin/jq" + "$jq_binary" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$opencode_env" >> "$GITHUB_ENV" + + opencode_binary="$ABXPKG_LIB_DIR/env/bin/opencode" + echo "OPENCODE_BINARY=$opencode_binary" >> "$GITHUB_ENV" + test -L "$opencode_binary" + test -x "$opencode_binary" - name: Check for duplicate issues env: @@ -33,7 +70,7 @@ jobs: "webfetch": "allow" } run: | - opencode run -m anthropic/claude-haiku-4-5 "A new issue has been created: + "$OPENCODE_BINARY" run -m anthropic/claude-haiku-4-5 "A new issue has been created: Issue number: ${{ github.event.issue.number }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b3d64672..c8d94af9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,13 +1,7 @@ name: Run linters on: - workflow_dispatch: - push: - branches: ['**'] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + workflow_call: env: UV_NO_SOURCES: "1" @@ -17,11 +11,23 @@ jobs: lint: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + architecture: x64 + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + - name: Clone abxpkg run: bash .github/scripts/clone_abx_repo.sh abxpkg @@ -31,18 +37,6 @@ jobs: - name: Clone abx-dl run: bash .github/scripts/clone_abx_repo.sh abx-dl - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - architecture: x64 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - version: "0.10.6" - enable-cache: false - - name: Install dependencies with uv run: | uv venv diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index cb1e02a1..06e81610 100755 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -1,66 +1,134 @@ name: Build Pip package on: - workflow_dispatch: - push: - branches: - - '**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + workflow_call: env: - PYTHON_VERSION: "3.13" + UV_VERSION: "0.11.3" jobs: build: + name: build distributions runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Install uv - uses: astral-sh/setup-uv@v6 + - name: Set up Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - version: "0.10.6" - enable-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: "3.13" architecture: x64 - - name: APT install archivebox dev + run dependencies - uses: awalsh128/cache-apt-pkgs-action@latest + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 with: - packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps - version: 1.0 + version: ${{ env.UV_VERSION }} + enable-cache: false - - name: UV install archivebox dev + run sub-dependencies - env: - UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv - run: uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache - - - name: UV build archivebox and archivebox/pkgs/* packages - run: | - uv build --all - - - name: UV install archivebox and archivebox/pkgs/* locally for tests - env: - UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv - run: | - uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache - uv pip install --python .venv/bin/python --no-deps dist/archivebox-*.whl - - - name: Verify built package full install - env: - UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv + - name: Build every distribution run: | set -Eeuo pipefail - DATA_DIR="$(mktemp -d)" - cd "$DATA_DIR" - uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox init - timeout 30m uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox install - uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox version - uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox status + uv build --all-packages --no-sources --out-dir dist --clear + + shopt -s nullglob + artifacts=(dist/*) + wheels=(dist/archivebox-*.whl) + sdists=(dist/archivebox-*.tar.gz) + [[ "${#artifacts[@]}" -gt 0 ]] + [[ "${#wheels[@]}" -eq 1 ]] + [[ "${#sdists[@]}" -eq 1 ]] + printf '%s\n' "$GITHUB_SHA" > dist/COMMIT_SHA + uv run --no-project python - <<'PY' + import hashlib + from pathlib import Path + + artifacts = sorted(Path("dist").iterdir()) + checksum_lines = [] + for artifact in artifacts: + if artifact.name == "SHA256SUMS": + continue + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + line = f"{digest} {artifact.name}" + checksum_lines.append(line) + print(line) + Path("dist/SHA256SUMS").write_text("\n".join(checksum_lines) + "\n") + PY + + - name: Upload every distribution + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-distributions + path: dist/* + if-no-files-found: error + retention-days: 7 + + install-smoke: + name: install ${{ matrix.artifact }} / Python ${{ matrix.python }} / ${{ matrix.os }} + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + python: ["3.13", "3.14"] + artifact: [wheel, sdist] + + steps: + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: ${{ env.UV_VERSION }} + enable-cache: false + + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-distributions + path: ${{ runner.temp }}/python-distributions + + - name: Install and smoke-test the built ${{ matrix.artifact }} + shell: bash + run: | + set -Eeuo pipefail + shopt -s nullglob + + case "${{ matrix.artifact }}" in + wheel) candidates=("$RUNNER_TEMP"/python-distributions/archivebox-*.whl) ;; + sdist) candidates=("$RUNNER_TEMP"/python-distributions/archivebox-*.tar.gz) ;; + *) exit 2 ;; + esac + [[ "${#candidates[@]}" -eq 1 ]] + artifact="${candidates[0]}" + + smoke_root="$(mktemp -d "$RUNNER_TEMP/archivebox-package-smoke.XXXXXX")" + trap 'rm -rf "$smoke_root"' EXIT + smoke_env="$smoke_root/env" + data_dir="$smoke_root/data" + mkdir -p "$data_dir" + + uv venv --python "${{ matrix.python }}" "$smoke_env" + uv pip install --python "$smoke_env" --no-cache "$artifact" + + unset PYTHONPATH + cd "$data_dir" + VIRTUAL_ENV="$smoke_env" uv run --active --no-project --no-sync python - <<'PY' + import os + from pathlib import Path + + import archivebox + + installed_path = Path(archivebox.__file__).resolve() + environment_path = Path(os.environ["VIRTUAL_ENV"]).resolve() + workspace_path = Path(os.environ["GITHUB_WORKSPACE"]).resolve() + assert installed_path.is_relative_to(environment_path), (installed_path, environment_path) + assert not installed_path.is_relative_to(workspace_path), (installed_path, workspace_path) + print(installed_path) + PY + VIRTUAL_ENV="$smoke_env" uv run --active --no-project --no-sync archivebox version + VIRTUAL_ENV="$smoke_env" uv run --active --no-project --no-sync archivebox init + VIRTUAL_ENV="$smoke_env" uv run --active --no-project --no-sync archivebox status diff --git a/.github/workflows/release-runner.yml b/.github/workflows/release-runner.yml deleted file mode 100644 index 2f43db98..00000000 --- a/.github/workflows/release-runner.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Release State - -on: - push: - branches: - - dev - workflow_dispatch: - -permissions: - contents: write - id-token: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - release-state: - if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'release:') - runs-on: ubuntu-latest - environment: pypi - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - ref: ${{ github.ref_name }} - token: ${{ secrets.RELEASE_GH_TOKEN || github.token }} - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - uses: astral-sh/setup-uv@v6 - with: - version: "0.10.6" - enable-cache: false - - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Run release script - env: - DEFAULT_BRANCH: dev - GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }} - run: ./bin/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bd70ab0a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,243 @@ +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [dev] + +permissions: + actions: read + contents: write + id-token: write + packages: write + +concurrency: + group: release-dev + cancel-in-progress: false + +env: + DOCKERHUB_IMAGE: archivebox/archivebox + GHCR_IMAGE: ghcr.io/archivebox/archivebox + RELEASE_BRANCH: dev + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + CI_RUN_ID: ${{ github.event.workflow_run.id }} + +jobs: + python-release: + name: Publish tested Python artifacts + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + runs-on: ubuntu-24.04 + environment: pypi + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + steps: + - name: Checkout the tested commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + submodules: true + ref: ${{ env.RELEASE_SHA }} + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + + - name: Resolve release binaries through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python -c 'import tomllib; print(next(package["version"] for package in tomllib.load(open("uv.lock", "rb"))["package"] if package["name"] == "abxpkg"))')" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env --install \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" >/dev/null + for name in uv gh git jq curl; do + test -L "$ABXPKG_LIB_DIR/env/bin/$name" + test -x "$ABXPKG_LIB_DIR/env/bin/$name" + done + { + echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv" + echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh" + echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git" + echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq" + echo "CURL_BINARY=$ABXPKG_LIB_DIR/env/bin/curl" + } >> "$GITHUB_ENV" + + - name: Download exact tested distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-distributions + path: ${{ runner.temp }}/python-distributions + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ env.CI_RUN_ID }} + + - name: Record release version + id: version + shell: bash + run: | + set -Eeuo pipefail + VERSION="$($UV_BINARY run --no-project python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish the exact tested distributions + env: + GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }} + RELEASE_DISTRIBUTIONS_DIR: ${{ runner.temp }}/python-distributions + run: ./bin/release.sh + + docker-release: + name: Publish tested multiarch image + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' + needs: python-release + runs-on: ubuntu-24.04 + steps: + - name: Checkout the tested commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 1 + ref: ${{ env.RELEASE_SHA }} + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + + - name: Resolve release and Docker binaries through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python -c 'import tomllib; print(next(package["version"] for package in tomllib.load(open("uv.lock", "rb"))["package"] if package["name"] == "abxpkg"))')" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env --install \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" >/dev/null + for name in uv git jq docker; do + test -L "$ABXPKG_LIB_DIR/env/bin/$name" + test -x "$ABXPKG_LIB_DIR/env/bin/$name" + done + { + echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv" + echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git" + echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq" + echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker" + } >> "$GITHUB_ENV" + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + with: + install: true + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Download exact tested image digests + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ env.CI_RUN_ID }} + + - name: Verify digest set and collect tags + id: docker_meta + shell: bash + run: | + set -Eeuo pipefail + mapfile -t DIGESTS < <($UV_BINARY run --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))') + [[ "${#DIGESTS[@]}" -eq 2 ]] + for digest in "${DIGESTS[@]}"; do [[ "$digest" =~ ^[0-9a-f]{64}$ ]]; done + + VERSION='${{ needs.python-release.outputs.version }}' + SHORT_SHA="${RELEASE_SHA::12}" + TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}^{}")" + TAG_TARGET="${TAG_TARGET%%[[:space:]]*}" + if [[ -z "$TAG_TARGET" ]]; then + TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}")" + TAG_TARGET="${TAG_TARGET%%[[:space:]]*}" + fi + { + echo 'dockerhub_tags<> "$GITHUB_OUTPUT" + + - name: Create Docker Hub manifest + shell: bash + run: | + set -Eeuo pipefail + mapfile -t DIGESTS < <($UV_BINARY run --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))') + TAG_ARGS=() + while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}' + REFS=() + for digest in "${DIGESTS[@]}"; do REFS+=("${DOCKERHUB_IMAGE}@sha256:${digest}"); done + $DOCKER_BINARY buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}" + + - name: Create GHCR manifest + shell: bash + run: | + set -Eeuo pipefail + mapfile -t DIGESTS < <($UV_BINARY run --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))') + TAG_ARGS=() + while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}' + REFS=() + for digest in "${DIGESTS[@]}"; do REFS+=("${GHCR_IMAGE}@sha256:${digest}"); done + $DOCKER_BINARY buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}" + + - name: Inspect published images + shell: bash + run: | + set -Eeuo pipefail + while IFS= read -r tag; do [[ -n "$tag" ]] && $DOCKER_BINARY buildx imagetools inspect "$tag"; done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}' + while IFS= read -r tag; do [[ -n "$tag" ]] && $DOCKER_BINARY buildx imagetools inspect "$tag"; done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}' + + - name: Update Docker Hub README + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: archivebox/archivebox + + downstream-packages: + name: Dispatch downstream packages + needs: + - python-release + - docker-release + uses: ./.github/workflows/update-homebrew-tap.yml + with: + release_tag: ${{ needs.python-release.outputs.tag }} + release_sha: ${{ github.event.workflow_run.head_sha }} + secrets: inherit diff --git a/.github/workflows/test-parallel.yml b/.github/workflows/test-parallel.yml index 7939cfb6..40eb9d98 100644 --- a/.github/workflows/test-parallel.yml +++ b/.github/workflows/test-parallel.yml @@ -1,22 +1,15 @@ name: Parallel Tests on: - workflow_dispatch: - pull_request: - branches: [dev, main, master] - push: - branches: [dev] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + workflow_call: env: PYTHONIOENCODING: utf-8 PYTHONLEGACYWINDOWSSTDIO: utf-8 USE_COLOR: False UV_NO_SOURCES: "1" - ABXPKG_MIN_RELEASE_AGE: "0" + CI_PYTHON_VERSION: "3.13.14" + UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache jobs: discover-tests: @@ -27,84 +20,150 @@ jobs: plugin-tests: ${{ steps.set-plugin-matrix.outputs.plugin-tests }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.CI_PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + + - name: Resolve matrix tools through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python -c 'import tomllib; print(next(package["version"] for package in tomllib.load(open("uv.lock", "rb"))["package"] if package["name"] == "abxpkg"))')" + test -n "$ABXPKG_VERSION" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env \ + --install \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + >/dev/null + JQ_BINARY="$ABXPKG_LIB_DIR/env/bin/jq" + PYTHON_BINARY="$ABXPKG_LIB_DIR/env/bin/python" + test -L "$JQ_BINARY" + test -x "$JQ_BINARY" + test -L "$PYTHON_BINARY" + test -x "$PYTHON_BINARY" + { + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + echo "JQ_BINARY=$JQ_BINARY" + echo "PYTHON_BINARY=$PYTHON_BINARY" + } >> "$GITHUB_ENV" + echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH" + - name: Discover test files id: set-matrix + shell: bash run: | - # Find all main test files - all_tests=$(find archivebox/tests -maxdepth 1 -name "test_*.py" -type f | sort) - - # Create JSON array with test file info - json_array="[" - first=true - for test_file in $all_tests; do - if [ "$first" = true ]; then - first=false - else - json_array+="," - fi - - # Extract a display name for the test - name="main/$(basename $test_file .py | sed 's/^test_//')" - needs_chromium=false - if grep -Eiq '(chrom|archivewebpage|PLUGINS=.*title|--plugins=.*title|SAVE_TITLE.*[Tt]rue)' "$test_file"; then - needs_chromium=true - fi - needs_sonic=false - if grep -Eiq "shutil\\.which\\([\"']sonic|SEARCH_BACKEND_ENGINE=.*sonic|worker_sonic" "$test_file"; then - needs_sonic=true - fi - - json_array+="{\"path\":\"$test_file\",\"name\":\"$name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}" - done - json_array+="]" - - echo "test-files=$json_array" >> $GITHUB_OUTPUT - echo "Found $(echo $all_tests | wc -w) test files" - echo "$json_array" | jq '.' + set -Eeuo pipefail + mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py archivebox) + test "${#output[@]}" -eq 2 + json_array="${output[1]}" + echo "test-files=$json_array" >> "$GITHUB_OUTPUT" + echo "${output[0]}" + echo "$json_array" | "$JQ_BINARY" '.' - name: Clone abx-plugins run: bash .github/scripts/clone_abx_repo.sh abx-plugins - name: Discover plugin tests id: set-plugin-matrix + shell: bash run: | - plugin_tests=$(find abx-plugins/abx_plugins/plugins -maxdepth 2 -type d -name tests | sed 's#abx-plugins/abx_plugins/plugins/##; s#/tests##' | sort) + set -Eeuo pipefail + mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py plugins) + test "${#output[@]}" -eq 2 + json_array="${output[1]}" + echo "plugin-tests=$json_array" >> "$GITHUB_OUTPUT" + echo "${output[0]}" + echo "$json_array" | "$JQ_BINARY" '.' - json_array="[" - first=true - for plugin_name in $plugin_tests; do - if [ "$first" = true ]; then - first=false - else - json_array+="," - fi + prepare-python-dependencies: + name: Prepare Python 3.13.14 dependency cache + runs-on: ubuntu-24.04 + env: + PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl + LIB_DIR: /tmp/abx-lib + ABXPKG_LIB_DIR: /tmp/abx-lib - needs_chromium=false - if grep -Riq "chrom" "abx-plugins/abx_plugins/plugins/$plugin_name"; then - needs_chromium=true - fi - needs_sonic=false - if [ "$plugin_name" = "search_backend_sonic" ]; then - needs_sonic=true - fi + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + submodules: true + fetch-depth: 1 - json_array+="{\"plugin\":\"$plugin_name\",\"name\":\"plugin/$plugin_name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}" - done - json_array+="]" + - name: Set up Python ${{ env.CI_PYTHON_VERSION }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.CI_PYTHON_VERSION }} + architecture: x64 - echo "plugin-tests=$json_array" >> $GITHUB_OUTPUT - echo "Found $(echo $plugin_tests | wc -w) plugin test suites" - echo "$json_array" | jq '.' + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + + - name: Clone abxpkg + run: bash .github/scripts/clone_abx_repo.sh abxpkg + + - name: Clone abx-plugins + run: bash .github/scripts/clone_abx_repo.sh abx-plugins + + - name: Clone abx-dl + run: bash .github/scripts/clone_abx_repo.sh abx-dl + + - name: Restore or create the SHA-specific uv cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ env.UV_CACHE_DIR }} + key: ${{ runner.os }}-python-${{ env.CI_PYTHON_VERSION }}-uv-${{ github.sha }} + + - name: Bootstrap local abxpkg + run: | + set -Eeuo pipefail + uv venv + uv pip install -e ./abxpkg + + - name: Resolve Linux build dependencies through abxpkg + run: | + set -Eeuo pipefail + mkdir -p "$LIB_DIR/env/bin" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" + uv run --no-sync --no-sources abxpkg env \ + --install \ + --no-cache \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries" + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-env.json:required_binaries" + + - name: Populate the full editable dependency cache + run: | + set -Eeuo pipefail + uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" + uv run --no-sync --no-sources python -c 'import ldap; print(ldap.__version__)' run-tests: name: ${{ matrix.test.name }} runs-on: ubuntu-24.04 - needs: discover-tests + needs: [discover-tests, prepare-python-dependencies] env: PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl CHROME_HEADLESS: "true" @@ -118,11 +177,23 @@ jobs: python: ["3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + architecture: x64 + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + - name: Clone abxpkg run: bash .github/scripts/clone_abx_repo.sh abxpkg @@ -132,56 +203,76 @@ jobs: - name: Clone abx-dl run: bash .github/scripts/clone_abx_repo.sh abx-dl - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - architecture: x64 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - version: "0.10.6" - - - name: Set up Node JS - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Cache uv - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - path: ~/.cache/uv - key: ${{ runner.os }}-${{ matrix.python }}-uv-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.python }}-uv- - - - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps - version: 1.1 + path: ${{ env.UV_CACHE_DIR }} + key: ${{ runner.os }}-python-${{ env.CI_PYTHON_VERSION }}-uv-${{ github.sha }} + fail-on-cache-miss: true - name: Install dependencies with uv run: | + set -Eeuo pipefail uv venv - uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" + uv pip install --offline --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" + + - name: Resolve Node.js through abxpkg + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + mkdir -p "$LIB_DIR/env/bin" + export PATH="$LIB_DIR/env/bin:$PATH" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" + + tooling_env="$( + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:node_binaries" + )" + JQ_BINARY="$LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$tooling_env" >> "$GITHUB_ENV" + + node_binary="$LIB_DIR/env/bin/node" + { + echo "JQ_BINARY=$JQ_BINARY" + echo "NODE_BINARY=$node_binary" + } >> "$GITHUB_ENV" + test -L "$JQ_BINARY" + test -x "$JQ_BINARY" + test -L "$node_binary" + test -x "$node_binary" + "$node_binary" --version - name: Resolve core test binaries through abxpkg env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail - mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + mkdir -p "$LIB_DIR/env/bin" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/wget/config.json:required_binaries" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/git/config.json:required_binaries" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_ripgrep/config.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Resolve Chrome through abxpkg @@ -189,17 +280,20 @@ jobs: env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/chrome/config.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Resolve Sonic through abxpkg @@ -207,28 +301,36 @@ jobs: env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_sonic/config.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Run test - ${{ matrix.test.name }} + env: + TEST_PATHS_JSON: ${{ toJson(matrix.test.paths) }} run: | + set -Eeuo pipefail + mapfile -t test_paths < <("$JQ_BINARY" -r '.[]' <<<"$TEST_PATHS_JSON") + [[ "${#test_paths[@]}" -gt 0 ]] mkdir -p tests/out - uv run --no-sync --no-sources pytest -xvs "${{ matrix.test.path }}" --basetemp=tests/out + uv run --no-sync --no-sources pytest -vs "${test_paths[@]}" --basetemp=tests/out plugin-tests: name: ${{ matrix.plugin.name }} runs-on: ubuntu-24.04 - needs: discover-tests + needs: [discover-tests, prepare-python-dependencies] env: PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl CHROME_HEADLESS: "true" @@ -241,11 +343,23 @@ jobs: plugin: ${{ fromJson(needs.discover-tests.outputs.plugin-tests) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + - name: Set up Python ${{ env.CI_PYTHON_VERSION }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.CI_PYTHON_VERSION }} + architecture: x64 + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + enable-cache: false + - name: Clone abxpkg run: bash .github/scripts/clone_abx_repo.sh abxpkg @@ -255,54 +369,74 @@ jobs: - name: Clone abx-dl run: bash .github/scripts/clone_abx_repo.sh abx-dl - - name: Set up Python 3.13 - uses: actions/setup-python@v5 - with: - python-version: "3.13" - architecture: x64 - - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - version: "0.10.6" - - - name: Set up Node JS - uses: actions/setup-node@v4 - with: - node-version: 22 - - name: Cache uv - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: - path: ~/.cache/uv - key: ${{ runner.os }}-3.13-uv-${{ hashFiles('pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-3.13-uv- - - - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps - version: 1.1 + path: ${{ env.UV_CACHE_DIR }} + key: ${{ runner.os }}-python-${{ env.CI_PYTHON_VERSION }}-uv-${{ github.sha }} + fail-on-cache-miss: true - name: Install dependencies with uv run: | + set -Eeuo pipefail uv venv - uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" + uv pip install --offline --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" + + - name: Resolve Node.js through abxpkg + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + mkdir -p "$LIB_DIR/env/bin" + export PATH="$LIB_DIR/env/bin:$PATH" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" + + tooling_env="$( + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:node_binaries" + )" + JQ_BINARY="$LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$tooling_env" >> "$GITHUB_ENV" + + node_binary="$LIB_DIR/env/bin/node" + { + echo "JQ_BINARY=$JQ_BINARY" + echo "NODE_BINARY=$node_binary" + } >> "$GITHUB_ENV" + test -L "$JQ_BINARY" + test -x "$JQ_BINARY" + test -L "$node_binary" + test -x "$node_binary" + "$node_binary" --version - name: Resolve plugin dependencies through abxpkg env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail - mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + mkdir -p "$LIB_DIR/env/bin" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ - --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/${{ matrix.plugin.plugin }}/config.json:required_binaries" \ + --deps-from="$GITHUB_WORKSPACE/${{ matrix.plugin.config_path }}:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Resolve Chrome through abxpkg @@ -310,17 +444,20 @@ jobs: env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/chrome/config.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Resolve Sonic through abxpkg @@ -328,23 +465,37 @@ jobs: env: LIB_DIR: ${{ runner.temp }}/abx-lib ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib - ABXPKG_INSTALL_TIMEOUT: "900" run: | set -euo pipefail mkdir -p "$LIB_DIR" - echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV" - echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" uv run --no-sync --no-sources abxpkg env \ --install \ + --json \ --lib="$LIB_DIR" \ --deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_sonic/config.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ >> "$GITHUB_ENV" - name: Run plugin tests - ${{ matrix.plugin.name }} env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }} API_KEY_2CAPTCHA: ${{ secrets.TWOCAPTCHA_API_KEY }} run: | - uv run --no-sync --no-sources bash ./bin/test_plugins.sh "${{ matrix.plugin.plugin }}" --no-coverage + set -Eeuo pipefail + test_path="$GITHUB_WORKSPACE/${{ matrix.plugin.test_path }}" + test -d "$test_path" + test_count="$("$LIB_DIR/env/bin/python" -c 'import pathlib, sys; print(sum(1 for _ in pathlib.Path(sys.argv[1]).rglob("test_*.py")))' "$test_path")" + test "$test_count" -gt 0 + DATA_DIR="$(mktemp -d -t archivebox_plugin_tests.XXXXXX)" + export DATA_DIR + plugin_tmpdir="$(mktemp -d -t archivebox_plugin_run.XXXXXX)" + cd "$plugin_tmpdir" + uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources python -m pytest \ + "$test_path" -p no:django -v --tb=short diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3de484de..70f1e2ef 100755 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,12 +1,7 @@ -name: Run tests +name: Integration Tests on: - push: - branches: ['**'] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + workflow_call: env: DOCKER_IMAGE: archivebox-ci @@ -23,16 +18,36 @@ jobs: PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl strategy: + fail-fast: false matrix: - os: [ubuntu-24.04] - python: ["3.13"] + include: + - os: ubuntu-24.04 + os_name: linux + python: "3.14" + - os: macos-15 + os_name: macOS + python: "3.13" + - os: macos-15 + os_name: macOS + python: "3.14" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + ### Set up Python + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + + - name: Install uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.11.3" + - name: Clone abxpkg run: bash .github/scripts/clone_abx_repo.sh abxpkg @@ -42,121 +57,329 @@ jobs: - name: Clone abx-dl run: bash .github/scripts/clone_abx_repo.sh abx-dl - ### Setup Python & JS Languages - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python }} - architecture: x64 - - - name: Install uv - uses: astral-sh/setup-uv@v4 - with: - version: "0.10.6" - - - name: Set up Node JS - uses: actions/setup-node@v4 - with: - node-version: 22 - - ### Install Python & JS Dependencies + ### Install dependencies - name: Cache uv - uses: actions/cache@v3 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/uv key: ${{ runner.os }}-${{ matrix.python }}-uv-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-${{ matrix.python }}-uv- - - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps - version: 1.0 + - name: Bootstrap local abxpkg + run: | + set -Eeuo pipefail + uv venv + uv pip install -e ./abxpkg + + - name: Prepare abxpkg environment + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + mkdir -p "$LIB_DIR/env/bin" + { + echo "LIB_DIR=$LIB_DIR" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" + } >> "$GITHUB_ENV" + echo "$LIB_DIR/env/bin" >> "$GITHUB_PATH" + + - name: Resolve Node.js through abxpkg + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + export PATH="$LIB_DIR/env/bin:$PATH" + tooling_env="$( + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:node_binaries" + )" + JQ_BINARY="$LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$tooling_env" >> "$GITHUB_ENV" + + node_binary="$LIB_DIR/env/bin/node" + { + echo "JQ_BINARY=$JQ_BINARY" + echo "NODE_BINARY=$node_binary" + } >> "$GITHUB_ENV" + test -L "$node_binary" + test -x "$node_binary" + "$node_binary" --version + + - name: Resolve Linux build dependencies through abxpkg + if: runner.os == 'Linux' + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + uv run --no-sync --no-sources abxpkg env \ + --install \ + --no-cache \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries" + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-env.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + >> "$GITHUB_ENV" + + - name: Resolve host Homebrew through abxpkg + if: runner.os == 'macOS' + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + brew_env="$( + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-brew.json:required_binaries" + )" + brew_binary="$("$JQ_BINARY" -r '.BREW_BINARY' <<< "$brew_env")" + test -L "$brew_binary" + echo "BREW_BINARY=$brew_binary" >> "$GITHUB_ENV" + + - name: Resolve macOS build dependencies through abxpkg + if: runner.os == 'macOS' + env: + LIB_DIR: ${{ runner.temp }}/abx-lib + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + run: | + set -Eeuo pipefail + brew_target="$(readlink "$BREW_BINARY")" + test -x "$brew_target" + brew_root="$(dirname "$(dirname "$brew_target")")" + export ABXPKG_BREW_ROOT="$brew_root" + + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-build.json:required_binaries" + + PATH="$brew_root/opt/openldap/bin:$PATH" \ + uv run --no-sync --no-sources abxpkg env \ + --install \ + --json \ + --lib="$LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-openldap-env.json:required_binaries" \ + | "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + >> "$GITHUB_ENV" + + ldapvc_target="$(readlink "$LIB_DIR/env/bin/ldapvc")" + test -x "$ldapvc_target" + openldap_prefix="$(dirname "$(dirname "$ldapvc_target")")" + test -f "$openldap_prefix/include/ldap.h" + test -f "$openldap_prefix/lib/libldap.dylib" + { + echo "ABXPKG_BREW_ROOT=$brew_root" + echo "CPPFLAGS=-I$openldap_prefix/include${CPPFLAGS:+ $CPPFLAGS}" + echo "LDFLAGS=-L$openldap_prefix/lib${LDFLAGS:+ $LDFLAGS}" + echo "PKG_CONFIG_PATH=$openldap_prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + } >> "$GITHUB_ENV" - name: Install dependencies with uv run: | - uv venv uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]" - ### Run the tests - - name: Directory listing for debugging - run: | - pwd - ls - - name: ArchiveBox full install check run: | set -Eeuo pipefail DATA_DIR="$PWD/tests/out/data" mkdir -p "$DATA_DIR" uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox init - timeout 30m uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox install + uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox install uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox version uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox status - - name: Test built package with pytest - if: github.ref != 'refs/heads/dev' + - name: Run consolidated core suite + if: matrix.os_name == 'macOS' || matrix.python == '3.14.6' run: | mkdir -p tests/out - uv run --no-sync --no-sources pytest -s archivebox/tests --basetemp=tests/out - - - name: Run documentation code blocks - run: uv run --no-sync --no-sources pytest -vv --tb=long README.md AGENTS.md skills docs - - - name: Run plugin tests - if: github.ref != 'refs/heads/dev' - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }} - API_KEY_2CAPTCHA: ${{ secrets.TWOCAPTCHA_API_KEY }} - run: | - uv run --no-sync --no-sources bash ./bin/test_plugins.sh --no-coverage + uv run --no-sync --no-sources pytest -q archivebox/tests --basetemp="tests/out/${{ matrix.os_name }}-python-${{ matrix.python }}" docker_tests: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: true fetch-depth: 1 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + + - name: Resolve Docker and curl through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python -c 'import tomllib; print(next(package["version"] for package in tomllib.load(open("uv.lock", "rb"))["package"] if package["name"] == "abxpkg"))')" + test -n "$ABXPKG_VERSION" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + echo "ABXPKG_VERSION=$ABXPKG_VERSION" >> "$GITHUB_ENV" + echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH" + + tooling_env="$( + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries" + )" + JQ_BINARY="$ABXPKG_LIB_DIR/env/bin/jq" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$tooling_env" >> "$GITHUB_ENV" + + for binary in "$ABXPKG_LIB_DIR/env/bin/docker" "$ABXPKG_LIB_DIR/env/bin/curl"; do + test -L "$binary" + test -x "$binary" + done + { + echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker" + echo "CURL_BINARY=$ABXPKG_LIB_DIR/env/bin/curl" + echo "JQ_BINARY=$JQ_BINARY" + } >> "$GITHUB_ENV" + - name: Build image run: | - docker build . -t "$DOCKER_IMAGE" + "$DOCKER_BINARY" build . -t "$DOCKER_IMAGE" + + - name: Resolve real mount test dependencies through abxpkg + run: | + set -Eeuo pipefail + mount_env="$( + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env \ + --install \ + --json \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:mount_validation_binaries" + )" + "$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \ + <<< "$mount_env" >> "$GITHUB_ENV" + + for binary_name in bash chmod exportfs mkdir mount.cifs mount.nfs smbd sudo systemctl umount; do + binary="$ABXPKG_LIB_DIR/env/bin/$binary_name" + test -L "$binary" + test -x "$binary" + done + + - name: Validate Docker UID and GID behavior on real NFS and SMB mounts + shell: bash + run: | + set -Eeuo pipefail + NFS_SOURCE="/srv/archivebox-ci-nfs" + SMB_SOURCE="/srv/archivebox-ci-smb" + NFS_MOUNT="$RUNNER_TEMP/archivebox-nfs-mount" + SMB_MOUNT="$RUNNER_TEMP/archivebox-smb-mount" + mounted_nfs=0 + mounted_smb=0 + + cleanup() { + local original_status=$? cleanup_status=0 + trap - EXIT + if [[ "$mounted_smb" == "1" ]] && ! "$SUDO_BINARY" "$UMOUNT_BINARY" "$SMB_MOUNT"; then + cleanup_status=1 + fi + if [[ "$mounted_nfs" == "1" ]] && ! "$SUDO_BINARY" "$UMOUNT_BINARY" "$NFS_MOUNT"; then + cleanup_status=1 + fi + if ! "$SUDO_BINARY" "$SYSTEMCTL_BINARY" stop smbd.service nfs-kernel-server.service; then + cleanup_status=1 + fi + if [[ "$original_status" != "0" ]]; then + exit "$original_status" + fi + exit "$cleanup_status" + } + trap cleanup EXIT + + "$SUDO_BINARY" "$MKDIR_BINARY" -p "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT" + "$SUDO_BINARY" "$CHMOD_BINARY" 0777 "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT" + "$SUDO_BINARY" "$BASH_BINARY" -c \ + "printf '%s *(rw,sync,no_subtree_check,no_root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports" + "$SUDO_BINARY" "$BASH_BINARY" -c \ + "printf '\\n[archivebox-ci]\\npath = %s\\nbrowseable = yes\\nread only = no\\nguest ok = yes\\nguest only = yes\\nforce user = nobody\\ncreate mask = 0777\\ndirectory mask = 0777\\n' '$SMB_SOURCE' >> /etc/samba/smb.conf" + + "$SUDO_BINARY" "$SYSTEMCTL_BINARY" restart nfs-kernel-server.service + "$SUDO_BINARY" "$EXPORTFS_BINARY" -ra + "$SYSTEMCTL_BINARY" is-active --quiet nfs-kernel-server.service + "$SUDO_BINARY" "$SYSTEMCTL_BINARY" restart smbd.service + "$SYSTEMCTL_BINARY" is-active --quiet smbd.service + + "$SUDO_BINARY" "$MOUNT_NFS_BINARY" -o vers=3,nolock "127.0.0.1:$NFS_SOURCE" "$NFS_MOUNT" + mounted_nfs=1 + "$SUDO_BINARY" "$MOUNT_CIFS_BINARY" //127.0.0.1/archivebox-ci "$SMB_MOUNT" \ + -o "guest,vers=3.0,uid=0,gid=0,forceuid,forcegid,file_mode=0777,dir_mode=0777,nounix" + mounted_smb=1 + + NFS_TEST_DIR="$NFS_MOUNT" \ + SMB_TEST_DIR="$SMB_MOUNT" \ + IMAGE="$DOCKER_IMAGE" \ + "$BASH_BINARY" bin/validate_docker_uid_gid.sh --local-only - name: Init data dir run: | - mkdir "${{ github.workspace }}/data" - docker run -v "${{ github.workspace }}/data":/data "$DOCKER_IMAGE" init + "$MKDIR_BINARY" "${{ github.workspace }}/data" + "$DOCKER_BINARY" run -v "${{ github.workspace }}/data":/data "$DOCKER_IMAGE" init - name: Run test server run: | - sudo bash -c 'echo "127.0.0.1 www.test-nginx-1.local www.test-nginx-2.local" >> /etc/hosts' - docker run --name www-nginx -p 80:80 -d nginx + "$SUDO_BINARY" "$BASH_BINARY" -c 'echo "127.0.0.1 www.test-nginx-1.local www.test-nginx-2.local" >> /etc/hosts' + "$DOCKER_BINARY" run --name www-nginx -p 80:80 -d nginx:1.29.5@sha256:0236ee02dcbce00b9bd83e0f5fbc51069e7e1161bd59d99885b3ae1734f3392e - name: Add link run: | - docker run -v "$PWD"/data:/data --network host "$DOCKER_IMAGE" add http://www.test-nginx-1.local + "$DOCKER_BINARY" run -v "$PWD"/data:/data --network host "$DOCKER_IMAGE" add http://www.test-nginx-1.local - name: Add stdin link run: | - echo "http://www.test-nginx-2.local" | docker run -i --network host -v "$PWD"/data:/data "$DOCKER_IMAGE" add + echo "http://www.test-nginx-2.local" | "$DOCKER_BINARY" run -i --network host -v "$PWD"/data:/data "$DOCKER_IMAGE" add - name: List links run: | - docker run -v "$PWD"/data:/data "$DOCKER_IMAGE" list | grep -q "www.test-nginx-1.local" || { echo "The site 1 isn't in the list"; exit 1; } - docker run -v "$PWD"/data:/data "$DOCKER_IMAGE" list | grep -q "www.test-nginx-2.local" || { echo "The site 2 isn't in the list"; exit 1; } + archive_list="$("$DOCKER_BINARY" run -v "$PWD"/data:/data "$DOCKER_IMAGE" list)" + [[ "$archive_list" == *"www.test-nginx-1.local"* ]] || { echo "The site 1 isn't in the list"; exit 1; } + [[ "$archive_list" == *"www.test-nginx-2.local"* ]] || { echo "The site 2 isn't in the list"; exit 1; } - name: Start docker-compose stack run: | - docker compose run --rm archivebox init - docker compose up -d --wait - curl --silent --location 'http://127.0.0.1:8000' | grep 'ArchiveBox' - curl --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js' | grep 'window.django' + "$DOCKER_BINARY" compose run --rm archivebox init + "$DOCKER_BINARY" compose up -d --wait + index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000')" + admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js')" + [[ "$index_html" == *ArchiveBox* ]] + [[ "$admin_js" == *window.django* ]] - name: Check added urls show up in index run: | - docker compose run --rm archivebox add --plugins=parse_txt_urls 'http://example.com/#test_docker' - curl --silent --location 'http://127.0.0.1:8000' | grep 'http://example.com/#test_docker' - docker compose down + "$DOCKER_BINARY" compose run --rm archivebox add --plugins=parse_txt_urls 'http://example.com/#test_docker' + index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000')" + [[ "$index_html" == *'http://example.com/#test_docker'* ]] + "$DOCKER_BINARY" compose down diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 803368af..79a345ab 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -1,37 +1,130 @@ name: Update downstream packages on: - push: - tags: ['v*'] + workflow_call: + inputs: + release_tag: + required: true + type: string + release_sha: + required: true + type: string workflow_dispatch: + inputs: + release_tag: + description: Exact ArchiveBox release tag + required: true + type: string + release_sha: + description: Full commit SHA owned by the release tag + required: true + type: string permissions: + actions: read contents: read concurrency: - group: update-downstream-packages-${{ github.ref }} - cancel-in-progress: true + group: update-downstream-packages-${{ inputs.release_tag || github.ref_name }} + cancel-in-progress: false jobs: dispatch: - runs-on: ubuntu-latest - + runs-on: ubuntu-24.04 steps: - - name: Dispatch downstream package updates + - name: Checkout release source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.release_sha || github.sha }} + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.3" + + - name: Resolve release and Docker binaries through abxpkg + env: + ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib + shell: bash + run: | + set -Eeuo pipefail + ABXPKG_VERSION="$(uv run --no-project python - <<'PY' + import tomllib + from pathlib import Path + + packages = tomllib.loads(Path("uv.lock").read_text())["package"] + matches = [package["version"] for package in packages if package["name"] == "abxpkg"] + if len(matches) != 1: + raise SystemExit(f"Expected one locked abxpkg package, found {len(matches)}") + print(matches[0]) + PY + )" + test -n "$ABXPKG_VERSION" + mkdir -p "$ABXPKG_LIB_DIR/env/bin" + echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV" + + uv run --no-project --with "abxpkg==$ABXPKG_VERSION" abxpkg env \ + --install \ + --lib="$ABXPKG_LIB_DIR" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \ + --deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" \ + >/dev/null + + for binary_name in uv gh git jq curl docker; do + binary="$ABXPKG_LIB_DIR/env/bin/$binary_name" + test -L "$binary" + test -x "$binary" + done + { + echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv" + echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh" + echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git" + echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq" + echo "CURL_BINARY=$ABXPKG_LIB_DIR/env/bin/curl" + echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker" + } >> "$GITHUB_ENV" + + - name: Verify the exact release and dispatch downstream builds env: DOWNSTREAM_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || secrets.HOMEBREW_TAP_TOKEN }} + GH_TOKEN: ${{ github.token }} + INPUT_RELEASE_TAG: ${{ inputs.release_tag }} + INPUT_RELEASE_SHA: ${{ inputs.release_sha }} + shell: bash run: | - if [ -z "$DOWNSTREAM_TOKEN" ]; then - echo "RELEASE_GH_TOKEN/HOMEBREW_TAP_TOKEN is not configured; downstream schedules will update packages." - exit 0 - fi + set -Eeuo pipefail + [[ -n "$DOWNSTREAM_TOKEN" ]] || { echo "RELEASE_GH_TOKEN/HOMEBREW_TAP_TOKEN is not configured" >&2; exit 1; } + RELEASE_TAG="${INPUT_RELEASE_TAG:-${GITHUB_REF_NAME}}" + [[ "$RELEASE_TAG" == v* ]] + VERSION="${RELEASE_TAG#v}" + + TAG_TARGET="$("$GIT_BINARY" ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}^{}" | "$UV_BINARY" run --no-project python -c 'import sys; print(next((line.split()[0] for line in sys.stdin if line.strip()), ""))')" + if [[ -z "$TAG_TARGET" ]]; then + TAG_TARGET="$("$GIT_BINARY" ls-remote "https://github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${RELEASE_TAG}" | "$UV_BINARY" run --no-project python -c 'import sys; print(next((line.split()[0] for line in sys.stdin if line.strip()), ""))')" + fi + RELEASE_SHA="${INPUT_RELEASE_SHA:-${TAG_TARGET}}" + [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$TAG_TARGET" == "$RELEASE_SHA" ]] || { echo "${RELEASE_TAG} points to ${TAG_TARGET}, not ${RELEASE_SHA}" >&2; exit 1; } + + "$CURL_BINARY" -fsSL "https://pypi.org/pypi/archivebox/${VERSION}/json" >/dev/null + "$DOCKER_BINARY" manifest inspect "archivebox/archivebox:${VERSION}" >/dev/null + "$DOCKER_BINARY" manifest inspect "ghcr.io/archivebox/archivebox:${VERSION}" >/dev/null + + PAYLOAD_FILTER="{event_type: \"archivebox-dev-updated\", client_payload: {ref: \$ref, sha: \$sha, version: \$version}}" + PAYLOAD="$("$JQ_BINARY" -nc --arg ref "$RELEASE_TAG" --arg sha "$RELEASE_SHA" --arg version "$VERSION" "$PAYLOAD_FILTER")" for repo in homebrew-archivebox debian-archivebox; do - curl --fail-with-body \ + "$CURL_BINARY" --fail-with-body \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${DOWNSTREAM_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "https://api.github.com/repos/ArchiveBox/${repo}/dispatches" \ - -d '{"event_type":"archivebox-dev-updated","client_payload":{"ref":"dev"}}' + --data "$PAYLOAD" done diff --git a/AGENTS.md b/AGENTS.md index b38e54f4..3936b3d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,19 +18,19 @@ ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev ## Development Setup ```bash -uv sync --dev --all-extras -mkdir -p data -cd data -uv run --project .. archivebox init --install +project_dir="${ARCHIVEBOX_PROJECT_DIR:-$PWD}" +archivebox_data="$(mktemp -d)" +uv sync --project "$project_dir" --dev --all-extras +cd "$archivebox_data" && uv run --project "$project_dir" --no-sync archivebox init --install ``` Run collection commands from inside an initialized data directory: ```bash -cd data -uv run --project .. archivebox status -uv run --project .. archivebox add 'https://example.com' -uv run --project .. archivebox run +project_dir="${ARCHIVEBOX_PROJECT_DIR:-$PWD}" +archivebox_data="$(mktemp -d)" +cd "$archivebox_data" +uv run --project "$project_dir" --no-sync archivebox init --install && uv run --project "$project_dir" --no-sync archivebox status && uv run --project "$project_dir" --no-sync archivebox add --plugins=parse_txt_urls "${ARCHIVEBOX_DOCS_URL_ONE:-https://example.com/}" && uv run --project "$project_dir" --no-sync archivebox run ``` ## User-Facing Setup @@ -38,12 +38,12 @@ uv run --project .. archivebox run Recommended CLI install: ```bash -uv tool install --force . +project_dir="${ARCHIVEBOX_PROJECT_DIR:-$PWD}" +tool_root="$(mktemp -d)"; export UV_TOOL_DIR="$tool_root/tools" UV_TOOL_BIN_DIR="$tool_root/bin" +uv tool install --force "$project_dir" export PLUGINS=parse_txt_urls archivebox_data="$(mktemp -d)" -cd "$archivebox_data" -archivebox init --install -archivebox add --plugins=parse_txt_urls 'https://example.com' +cd "$archivebox_data" && "$UV_TOOL_BIN_DIR/archivebox" init --install && "$UV_TOOL_BIN_DIR/archivebox" add --plugins=parse_txt_urls "${ARCHIVEBOX_DOCS_URL_ONE:-https://example.com/}" ``` Alternative install methods: @@ -57,16 +57,16 @@ Alternative install methods: ```bash -archivebox version -archivebox help -archivebox status -archivebox install -archivebox add --plugins=parse_txt_urls 'https://example.com/docs-basic-usage' -archivebox list --json --with-headers -archivebox search 'example' -archivebox update --filter-type=domain example.com -archivebox remove --filter-type=exact 'https://example.com' -archivebox run +project_dir="${ARCHIVEBOX_PROJECT_DIR:-$PWD}" +archivebox_data="$(mktemp -d)" +cd "$archivebox_data" && uv run --project "$project_dir" --no-sync archivebox init --install +uv run --project "$project_dir" --no-sync archivebox version && uv run --project "$project_dir" --no-sync archivebox help && uv run --project "$project_dir" --no-sync archivebox status +uv run --project "$project_dir" --no-sync archivebox install +uv run --project "$project_dir" --no-sync archivebox add --plugins=parse_txt_urls "${ARCHIVEBOX_DOCS_URL_ONE:-https://example.com/docs-basic-usage}" +uv run --project "$project_dir" --no-sync archivebox list --json --with-headers +uv run --project "$project_dir" --no-sync archivebox search 'example' && uv run --project "$project_dir" --no-sync archivebox update --filter-type=domain example.com +uv run --project "$project_dir" --no-sync archivebox remove --yes --delete --filter-type=exact "${ARCHIVEBOX_DOCS_URL_ONE:-https://example.com/docs-basic-usage}" +uv run --project "$project_dir" --no-sync archivebox run ``` ## Verification @@ -74,8 +74,9 @@ archivebox run Use targeted tests for focused work: ```bash -uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_tag_options -q -uv run prek run --all-files +project_dir="${ARCHIVEBOX_PROJECT_DIR:-.}" +uv run --project "$project_dir" --no-sync pytest "$project_dir/archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_tag_options" -q +(cd "$project_dir" && uv run --no-sync prek run --all-files) ``` Use the full release/deploy loop only when requested: diff --git a/Dockerfile b/Dockerfile index c49f7e66..f43062dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,10 @@ # --build-context abx-plugins=../abx-plugins \ # -t archivebox/abx-dl:dev # docker buildx build . -f Dockerfile \ -# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:latest \ +# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.11.263 \ # -t archivebox:multistage -ARG ABX_DL_IMAGE=archivebox/abx-dl:latest +ARG ABX_DL_IMAGE=archivebox/abx-dl:1.11.263 FROM archivebox/sonic:1.4.9 AS sonic FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base @@ -66,9 +66,6 @@ ENV TMP_DIR=/tmp/archivebox \ ENV HOME=/home/archivebox \ XDG_CONFIG_HOME=/home/archivebox/.config \ XDG_CACHE_HOME=/opt/archivebox/lib/cache \ - ABXPKG_INSTALL_TIMEOUT=600 \ - ABXPKG_POSTINSTALL_SCRIPTS=True \ - ABXPKG_MIN_RELEASE_AGE=0 \ TIMEOUT=600 ENV UV_COMPILE_BYTECODE=false \ diff --git a/README.md b/README.md index 00b95245..705c62b4 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,8 @@
- -     - -

@@ -171,7 +165,7 @@ ArchiveBox is free for everyone to self-host, but we also provide support, secur # Quickstart -**đŸ–Ĩ  [Supported OSs](https://github.com/ArchiveBox/ArchiveBox/wiki/Install#supported-systems):** Linux/BSD, macOS, Windows (Docker)   **👾  CPUs:** `amd64` (`x86_64`), `arm64`, `arm7` (raspi>=3)
+**đŸ–Ĩ  [Supported OSs](https://github.com/ArchiveBox/ArchiveBox/wiki/Install#supported-systems):** Linux/BSD, macOS, Windows (Docker)   **👾  CPUs:** `amd64` (`x86_64`), `arm64`, `arm7`

@@ -1291,7 +1285,6 @@ ArchiveBox is neither the highest fidelity nor the simplest tool available for s
- ## Internet Archiving Ecosystem diff --git a/archivebox/__init__.py b/archivebox/__init__.py index d8d69109..898e7932 100755 --- a/archivebox/__init__.py +++ b/archivebox/__init__.py @@ -38,11 +38,6 @@ ASCII_LOGO = """ PACKAGE_DIR = Path(__file__).resolve().parent -# # Add PACKAGE_DIR to sys.path - required for Django migrations to import models -# # Migrations reference models like 'machine.Binary' which need to be importable -# if str(PACKAGE_DIR) not in sys.path: -# sys.path.append(str(PACKAGE_DIR)) - os.environ["DJANGO_SETTINGS_MODULE"] = "archivebox.core.settings" os.environ["TZ"] = "UTC" diff --git a/archivebox/api/urls.py b/archivebox/api/urls.py index ced60588..0d460098 100644 --- a/archivebox/api/urls.py +++ b/archivebox/api/urls.py @@ -23,7 +23,4 @@ urlpatterns = [ path("v1/", RedirectView.as_view(url="/api/v1/docs")), path("v1/", v1_api_urls), path("v1", RedirectView.as_view(url="/api/v1/docs")), - # ... v2 can be added here ... - # path("v2/", v2_api_urls), - # path("v2", RedirectView.as_view(url='/api/v2/docs')), ] diff --git a/archivebox/api/v1_api.py b/archivebox/api/v1_api.py index a6784e6d..8925b4e3 100644 --- a/archivebox/api/v1_api.py +++ b/archivebox/api/v1_api.py @@ -78,9 +78,6 @@ class NinjaAPIWithIOCapture(NinjaAPI): response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if request.user.pk else "None" response["X-ArchiveBox-Auth-User-Username"] = request.user.username if isinstance(request.user, User) else "None" - # import ipdb; ipdb.set_trace() - # print('RESPONDING NOW', response) - return response @@ -91,8 +88,6 @@ api = NinjaAPIWithIOCapture( auth=API_AUTH_METHODS, urls_namespace="api-1", docs=Swagger(settings={"persistAuthorization": True}), - # docs_decorator=login_required, - # renderer=ORJSONRenderer(), ) api = register_urls(api) urls = api.urls @@ -113,24 +108,7 @@ def generic_exception_handler(request, err): "message": f"{err.__class__.__name__}: {err}", "errors": [ "".join(format_exception(err)), - # or send simpler parent-only traceback: - # *([str(err.__context__)] if getattr(err, '__context__', None) else []), ], }, status=status, ) - - -# import orjson -# from ninja.renderers import BaseRenderer -# class ORJSONRenderer(BaseRenderer): -# media_type = "application/json" -# def render(self, request, data, *, response_status): -# return { -# "success": True, -# "errors": [], -# "result": data, -# "stdout": ansi_to_html(stdout.getvalue().strip()), -# "stderr": ansi_to_html(stderr.getvalue().strip()), -# } -# return orjson.dumps(data) diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py index 7f898164..297838d1 100644 --- a/archivebox/api/v1_cli.py +++ b/archivebox/api/v1_cli.py @@ -15,8 +15,6 @@ from archivebox.misc.util import ansi_to_html from archivebox.core.models import SnapshotQuerySet -# from .auth import API_AUTH_METHODS - # router for API that exposes archivebox cli subcommands as REST endpoints router = Router(tags=["ArchiveBox CLI Sub-Commands"]) diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py index e0bcbcf9..5f31d3b5 100644 --- a/archivebox/api/v1_crawls.py +++ b/archivebox/api/v1_crawls.py @@ -49,8 +49,6 @@ class CrawlSchema(Schema): tags_str: str config: dict - # snapshots: List[SnapshotSchema] - @staticmethod def resolve_created_by_id(obj): return str(obj.created_by_id) diff --git a/archivebox/base_models/apps.py b/archivebox/base_models/apps.py index 82bd72f8..e69de29b 100644 --- a/archivebox/base_models/apps.py +++ b/archivebox/base_models/apps.py @@ -1,7 +0,0 @@ -# from django.apps import AppConfig - - -# class BaseModelsConfig(AppConfig): -# default_auto_field = 'django.db.models.BigAutoField' - -# name = 'base_models' diff --git a/archivebox/cli/__init__.py b/archivebox/cli/__init__.py index 031af38f..12624d2e 100644 --- a/archivebox/cli/__init__.py +++ b/archivebox/cli/__init__.py @@ -128,16 +128,12 @@ class ArchiveBoxGroup(click.Group): import_path = cmd_name_or_path modname, funcname = import_path.rsplit(".", 1) - # print(f'LAZY LOADING {import_path}') mod = import_module(modname) func = vars(mod)[funcname] if func.__doc__ is None: raise ValueError(f"lazy loading of {import_path} failed - no docstring found on method") - # if not isinstance(cmd, click.BaseCommand): - # raise ValueError(f'lazy loading of {import_path} failed - not a click command') - return func @@ -158,7 +154,6 @@ def cli(ctx, help=False): # then we need to set up the django environment and check that we're in a valid data folder wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:]) if not wants_help and (subcommand in ArchiveBoxGroup.archive_commands or subcommand in ArchiveBoxGroup.model_commands): - # print('SETUP DJANGO AND CHECK DATA FOLDER') try: if subcommand == "server": run_in_debug = "--reload" in sys.argv or os.environ.get("DEBUG") in ("1", "true", "True", "TRUE", "yes") diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py index b6fe5817..9e0ea425 100644 --- a/archivebox/cli/archivebox_add.py +++ b/archivebox/cli/archivebox_add.py @@ -216,7 +216,7 @@ def add( ) from archivebox.services.runner import ensure_background_runner - ensure_background_runner(allow_under_pytest=True) + ensure_background_runner() else: # Foreground mode: run full crawl runner until all work is done print("[green]\\[*] Starting crawl runner to process crawl...[/green]") diff --git a/archivebox/cli/archivebox_config.py b/archivebox/cli/archivebox_config.py index 5dc50528..54317816 100644 --- a/archivebox/cli/archivebox_config.py +++ b/archivebox/cli/archivebox_config.py @@ -59,28 +59,20 @@ def config( matching_config = {} if search: if config_options: - config_options = [ - core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options - ] - matching_config = {key: readable_config[key] for key in config_options if key in readable_config} - for config_section in CONFIGS.values(): - aliases = {str(field.alias): field_name for field_name, field in type(config_section).model_fields.items() if field.alias} + search_terms = [key.strip().lower() for key in config_options] - for search_key in config_options: - # search all aliases in the section - for alias_key, key in aliases.items(): - if key in readable_config and search_key.lower() in alias_key.lower(): - matching_config[key] = dict(config_section)[key] + for existing_key, value in readable_config.items(): + if any(term in existing_key.lower() or term in str(value).lower() for term in search_terms): + matching_config[existing_key] = value - # search all keys and values in the section - for existing_key, value in dict(config_section).items(): - if existing_key in readable_config and ( - search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower() - ): - matching_config[existing_key] = value - for existing_key, value in readonly_config.items(): - if search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower(): - matching_config[existing_key] = value + for alias, key in core_config_aliases.items(): + if key in readable_config and any(term in alias.lower() for term in search_terms): + matching_config[key] = readable_config[key] + + for schema in plugin_schemas.values(): + for key, metadata in schema.items(): + if key in readable_config and any(term in key.lower() or term in str(metadata).lower() for term in search_terms): + matching_config[key] = readable_config[key] print(printable_config(matching_config)) raise SystemExit(not matching_config) diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py index 4826cf43..f20d3d16 100755 --- a/archivebox/cli/archivebox_init.py +++ b/archivebox/cli/archivebox_init.py @@ -33,10 +33,6 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non config = get_config() - # if os.access(out_dir / CONSTANTS.JSON_INDEX_FILENAME, os.F_OK): - # print("[red]:warning: This folder contains a JSON index. It is deprecated, and will no longer be kept up to date automatically.[/red]", file=sys.stderr) - # print("[red] You can run `archivebox list --json --with-headers > static_index.json` to manually generate it.[/red]", file=sys.stderr) - is_empty = not len(set(os.listdir(CONSTANTS.DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR) existing_index = os.path.isfile(CONSTANTS.DATABASE_FILE) if is_empty and not existing_index: @@ -111,9 +107,6 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non print() print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}") - # from django.contrib.auth.models import User - # call_command("createsuperuser", interactive=True) - print() print("[dodger_blue3][*] Checking links from indexes and archive folders (safe to Ctrl+C)...[/dodger_blue3]") diff --git a/archivebox/cli/archivebox_oneshot.py b/archivebox/cli/archivebox_oneshot.py index 560ba7e8..419f2aca 100644 --- a/archivebox/cli/archivebox_oneshot.py +++ b/archivebox/cli/archivebox_oneshot.py @@ -4,11 +4,13 @@ __package__ = "archivebox.cli" __command__ = "archivebox oneshot" import subprocess +import sys from pathlib import Path import rich_click as click from archivebox.config import CONSTANTS +from archivebox.config.common import get_config @click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True)) @@ -20,7 +22,23 @@ def main(args: tuple[str, ...] = ()) -> None: raise click.ClickException( "Refusing to run `archivebox oneshot` inside an ArchiveBox DATA_DIR. Use `archivebox add` here, or run oneshot from another directory.", ) - raise SystemExit(subprocess.run(["abx-dl", *args]).returncode) + abxpkg_binary = Path(sys.executable).with_name("abxpkg") + if not abxpkg_binary.is_file(): + raise click.ClickException(f"abxpkg executable is missing from the ArchiveBox environment: {abxpkg_binary}") + abxpkg_lib_dir = get_config(include_machine=False).ABXPKG_LIB_DIR + raise SystemExit( + subprocess.run( + [ + str(abxpkg_binary), + f"--lib={abxpkg_lib_dir}", + "--binproviders=env", + "--install", + "run", + "abx-dl", + *args, + ], + ).returncode, + ) if __name__ == "__main__": diff --git a/archivebox/cli/archivebox_remove.py b/archivebox/cli/archivebox_remove.py index bd38f8e1..8942e0c9 100644 --- a/archivebox/cli/archivebox_remove.py +++ b/archivebox/cli/archivebox_remove.py @@ -9,7 +9,6 @@ from collections.abc import Iterable import rich_click as click -from django.db import OperationalError from django.db.models import QuerySet from archivebox.config import CONSTANTS @@ -92,12 +91,6 @@ def remove( # in ``base_models/models.py`` and runs AFTER its row's tx commits — so # rmtree doesn't hold the lock either. # - # The SQLite retry wrapper in core/sqlite_backend/base.py re-raises lock - # errors when called inside an atomic block (because it can't safely - # release+reacquire a transaction), so we wrap each row's delete in our - # own retry loop at this outer (non-atomic) level. Each attempt is a - # fresh atomic; an exception cleanly rolls it back before we sleep. - retry_interval = 1.0 deleted_snapshot_pks = [] timed_out = False timeout_error = "" @@ -106,23 +99,9 @@ def remove( timed_out = True timeout_error = f"Remove timed out after {timeout:g}s with {len(snapshot_pks) - index} snapshots remaining." break - while True: - try: - deleted_count, _ = Snapshot.objects.filter(pk=pk).delete() - if deleted_count: - deleted_snapshot_pks.append(pk) - break - except OperationalError as err: - if "database is locked" not in str(err): - raise - remaining_time = deadline - time.monotonic() if deadline is not None else None - if remaining_time is not None and remaining_time <= 0: - timed_out = True - timeout_error = f"Remove timed out after {timeout:g}s while waiting for the database lock." - break - time.sleep(min(retry_interval, remaining_time) if remaining_time is not None else retry_interval) - if timed_out: - break + deleted_count, _ = Snapshot.objects.filter(pk=pk).delete() + if deleted_count: + deleted_snapshot_pks.append(pk) all_snapshots = Snapshot.objects.all() remaining_count = all_snapshots.count() diff --git a/archivebox/cli/archivebox_server.py b/archivebox/cli/archivebox_server.py index 16c64d22..db8255b0 100644 --- a/archivebox/cli/archivebox_server.py +++ b/archivebox/cli/archivebox_server.py @@ -267,11 +267,13 @@ def server( host, port = _parse_and_validate_bind_spec(bind_spec) if daemonize and os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1": + from archivebox.workers.supervisord_util import resolve_env_binary + log_path = CONSTANTS.LOGS_DIR / "server.log" log_path.parent.mkdir(parents=True, exist_ok=True) daemon_env = os.environ.copy() daemon_env["ARCHIVEBOX_SERVER_DAEMON_CHILD"] = "1" - daemon_cmd = [sys.executable, "-m", "archivebox", "server"] + daemon_cmd = [str(resolve_env_binary("archivebox")), "server"] if debug: daemon_cmd.append("--debug") if reload: diff --git a/archivebox/cli/archivebox_snapshot.py b/archivebox/cli/archivebox_snapshot.py index c1509e77..49321f09 100644 --- a/archivebox/cli/archivebox_snapshot.py +++ b/archivebox/cli/archivebox_snapshot.py @@ -31,14 +31,62 @@ __package__ = "archivebox.cli" __command__ = "archivebox snapshot" import sys -from collections.abc import Iterable +from collections.abc import Iterable, Iterator +from itertools import islice import rich_click as click from rich import print as rprint from django.db.models import QuerySet SNAPSHOT_FILTER_TYPE_CHOICES = ("exact", "substring", "regex", "domain", "tag", "timestamp") -SNAPSHOT_LIST_CHUNK_SIZE = 100 +SNAPSHOT_LIST_CHUNK_SIZE = 5000 + + +def iter_snapshot_json(queryset: QuerySet) -> Iterator[dict[str, object]]: + from archivebox.config import VERSION + from archivebox.core.models import SnapshotTag + + fields = ( + "id", + "crawl_id", + "url", + "title", + "bookmarked_at", + "created_at", + "timestamp", + "depth", + "status", + "fs_version", + "output_size", + ) + rows = queryset.values(*fields).iterator(chunk_size=SNAPSHOT_LIST_CHUNK_SIZE) + while batch := list(islice(rows, SNAPSHOT_LIST_CHUNK_SIZE)): + tags_by_snapshot = {row["id"]: [] for row in batch} + tag_rows = ( + SnapshotTag.objects.filter(snapshot_id__in=tags_by_snapshot).order_by("tag__name").values_list("snapshot_id", "tag__name") + ) + for snapshot_id, tag_name in tag_rows: + tags_by_snapshot[snapshot_id].append(tag_name) + + for row in batch: + archive_size = int(row["output_size"] or 0) + yield { + "type": "Snapshot", + "schema_version": VERSION, + "id": str(row["id"]), + "crawl_id": str(row["crawl_id"]), + "url": row["url"], + "title": row["title"], + "tags": ",".join(sorted(tags_by_snapshot[row["id"]])), + "bookmarked_at": row["bookmarked_at"].isoformat() if row["bookmarked_at"] else None, + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "timestamp": row["timestamp"], + "depth": row["depth"], + "status": row["status"], + "fs_version": row["fs_version"], + "archive_size": archive_size, + "output_size": archive_size, + } # ============================================================================= @@ -296,8 +344,8 @@ def list_snapshots( return 0 if not is_tty: - for snapshot in queryset.prefetch_related("tags").iterator(chunk_size=SNAPSHOT_LIST_CHUNK_SIZE): - write_record(snapshot.to_json()) + for snapshot_json in iter_snapshot_json(queryset): + write_record(snapshot_json) count += 1 rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr) return 0 diff --git a/archivebox/config/constants.py b/archivebox/config/constants.py index 53f3a780..3ba2bb3d 100644 --- a/archivebox/config/constants.py +++ b/archivebox/config/constants.py @@ -293,5 +293,3 @@ CONSTANTS = ConstantsDict CONSTANTS_CONFIG = AttrDict({key: value for key, value in CONSTANTS.__dict__.items() if key.isupper() and not key.startswith("_")}) # add all key: values to globals() for easier importing, e.g.: -# from archivebox.config.constants import IS_ROOT, PERSONAS_DIR, ... -# globals().update(CONSTANTS) diff --git a/archivebox/config/django.py b/archivebox/config/django.py index 538a9e7d..9f326522 100644 --- a/archivebox/config/django.py +++ b/archivebox/config/django.py @@ -2,7 +2,6 @@ __package__ = "archivebox.config" import os import sys -import subprocess from datetime import datetime, timezone @@ -37,7 +36,6 @@ def setup_django(check_db=False, in_memory_db=False) -> None: global DJANGO_SET_UP if DJANGO_SET_UP: - # raise Exception('django is already set up!') # TODO: figure out why CLI entrypoints with init_pending are running this twice sometimes return @@ -59,10 +57,10 @@ def setup_django(check_db=False, in_memory_db=False) -> None: with SudoPermission(uid=0): # running as root is a special case where it's ok to be a bit slower # make sure data dir is always owned by the correct user - subprocess.run(["chown", f"{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}", str(CONSTANTS.DATA_DIR)], stderr=subprocess.DEVNULL) + os.chown(CONSTANTS.DATA_DIR, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP) if CONSTANTS.DATA_DIR.exists(): for child in CONSTANTS.DATA_DIR.iterdir(): - subprocess.run(["chown", f"{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}", str(child)], stderr=subprocess.DEVNULL) + os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP) # Suppress the "database access during app initialization" warning # This warning can be triggered during django.setup() but is safe to ignore @@ -156,17 +154,6 @@ def setup_django(check_db=False, in_memory_db=False) -> None: f"No database file {sql_index_path} found in: {CONSTANTS.DATA_DIR} (Are you in an ArchiveBox collection directory?)" ) - # https://docs.pydantic.dev/logfire/integrations/django/ Logfire Debugging - # if settings.DEBUG_LOGFIRE: - # from opentelemetry.instrumentation.sqlite3 import SQLite3Instrumentor - # SQLite3Instrumentor().instrument() - - # import logfire - - # logfire.configure() - # logfire.instrument_django(is_sql_commentor_enabled=True) - # logfire.info(f'Started ArchiveBox v{CONSTANTS.VERSION}', argv=sys.argv) - except KeyboardInterrupt: raise diff --git a/archivebox/config/paths.py b/archivebox/config/paths.py index be256688..036cc550 100644 --- a/archivebox/config/paths.py +++ b/archivebox/config/paths.py @@ -5,7 +5,6 @@ import socket import hashlib import tempfile import platform -import subprocess from pathlib import Path from functools import cache from datetime import datetime @@ -69,9 +68,9 @@ def _get_collection_id(DATA_DIR=DATA_DIR, force_create=False) -> str: if IS_ROOT: with SudoPermission(uid=0): if ARCHIVEBOX_USER == 0: - subprocess.run(["chmod", "777", str(collection_id_file)]) + collection_id_file.chmod(0o777) else: - subprocess.run(["chown", str(ARCHIVEBOX_USER), str(collection_id_file)]) + os.chown(collection_id_file, ARCHIVEBOX_USER, -1) except (OSError, FileNotFoundError, PermissionError): pass return collection_id @@ -129,7 +128,7 @@ def dir_is_writable(dir_path: Path, uid: int | None = None, gid: int | None = No if chown: # try fixing it using sudo permissions with SudoPermission(uid=uid, fallback=fallback): - subprocess.run(["chown", f"{uid}:{gid}", str(dir_path)], stderr=subprocess.DEVNULL) + os.chown(dir_path, uid, gid) return dir_is_writable(dir_path, uid=uid, gid=gid, fallback=fallback, chown=False) return False @@ -348,11 +347,6 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs and os.access(tmp_dir, os.W_OK) and tmp_dir_socket_path_is_short_enough(tmp_dir), }, - # "CACHE_DIR": { - # "path": CACHE_DIR.resolve(), - # "enabled": True, - # "is_valid": os.access(CACHE_DIR, os.R_OK) and os.access(CACHE_DIR, os.W_OK), # read + write - # }, }, ) diff --git a/archivebox/config/permissions.py b/archivebox/config/permissions.py index 8868a812..0a9edd48 100644 --- a/archivebox/config/permissions.py +++ b/archivebox/config/permissions.py @@ -37,8 +37,6 @@ HOSTNAME: str = cast(str, max([socket.gethostname(), platform.node()], key=len)) IS_ROOT = RUNNING_AS_UID == 0 IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes") -# IN_DOCKER_COMPOSE = # TODO: figure out a way to detect if running in docker compose - FALLBACK_UID = RUNNING_AS_UID or SUDO_UID FALLBACK_GID = RUNNING_AS_GID or SUDO_GID diff --git a/archivebox/config/version.py b/archivebox/config/version.py index eeb526cd..55aca3c2 100644 --- a/archivebox/config/version.py +++ b/archivebox/config/version.py @@ -36,7 +36,6 @@ def detect_installed_version(PACKAGE_DIR: Path = PACKAGE_DIR): # building docs, pyproject.toml is not available pass - # raise Exception('Failed to detect installed archivebox version!') return "dev" @@ -115,53 +114,4 @@ def get_BUILD_TIME() -> str: return datetime.fromtimestamp(src_last_modified_unix_timestamp).strftime("%Y-%m-%d %H:%M:%S %s") -# def get_versions_available_on_github(config): -# """ -# returns a dictionary containing the ArchiveBox GitHub release info for -# the recommended upgrade version and the currently installed version -# """ - -# # we only want to perform the (relatively expensive) check for new versions -# # when its most relevant, e.g. when the user runs a long-running command -# subcommand_run_by_user = sys.argv[3] if len(sys.argv) > 3 else 'help' -# long_running_commands = ('add', 'schedule', 'update', 'status', 'server') -# if subcommand_run_by_user not in long_running_commands: -# return None - -# github_releases_api = "https://api.github.com/repos/ArchiveBox/ArchiveBox/releases" -# response = requests.get(github_releases_api) -# if response.status_code != 200: -# stderr(f'[!] Warning: GitHub API call to check for new ArchiveBox version failed! (status={response.status_code})', color='lightyellow', config=config) -# return None -# all_releases = response.json() - -# installed_version = parse_version_string(config['VERSION']) - -# # find current version or nearest older version (to link to) -# current_version = None -# for idx, release in enumerate(all_releases): -# release_version = parse_version_string(release['tag_name']) -# if release_version <= installed_version: -# current_version = release -# break - -# current_version = current_version or all_releases[-1] - -# # recommended version is whatever comes after current_version in the release list -# # (perhaps too conservative to only recommend upgrading one version at a time, but it's safest) -# try: -# recommended_version = all_releases[idx+1] -# except IndexError: -# recommended_version = None - -# return {'recommended_version': recommended_version, 'current_version': current_version} - -# def can_upgrade(config): -# if config['VERSIONS_AVAILABLE'] and config['VERSIONS_AVAILABLE']['recommended_version']: -# recommended_version = parse_version_string(config['VERSIONS_AVAILABLE']['recommended_version']['tag_name']) -# current_version = parse_version_string(config['VERSIONS_AVAILABLE']['current_version']['tag_name']) -# return recommended_version > current_version -# return False - - VERSION: str = detect_installed_version() diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index db569ceb..c4b153b6 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -328,15 +328,12 @@ class ArchiveResultInline(admin.TabularInline): name = "Archive Results Log" model = ArchiveResult parent_model = Snapshot - # fk_name = 'snapshot' extra = 0 sort_fields = ("end_ts", "plugin", "output_str", "status", "cmd_version") readonly_fields = ("id", "result_id", "completed", "command", "version") fields = ("start_ts", "end_ts", *readonly_fields, "plugin", "cmd", "cmd_version", "pwd", "status", "output_str") - # exclude = ('id',) ordering = ("end_ts",) show_change_link = True - # # classes = ['collapse'] def get_parent_object_from_request(self, request): resolved = resolve(request.path_info) @@ -371,7 +368,6 @@ class ArchiveResultInline(admin.TabularInline): base_fields = formset.form.base_fields snapshot_output_dir = str(snapshot.output_dir) if snapshot else "" - # import ipdb; ipdb.set_trace() # formset.form.base_fields['id'].widget = formset.form.base_fields['id'].hidden_widget() # default values for new entries @@ -817,8 +813,6 @@ class ArchiveResultAdmin(BaseModelAdmin): else: root_dir = str(snapshot_dir) - # print(root_dir, str(list(os.walk(root_dir)))) - for root, dirs, files in os.walk(root_dir): depth = root.replace(root_dir, "").count(os.sep) + 1 if depth > 2: diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py index 693b8dcf..6abb2cee 100644 --- a/archivebox/core/admin_snapshots.py +++ b/archivebox/core/admin_snapshots.py @@ -43,7 +43,6 @@ from archivebox.core.permissions import ( from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget -# GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': [], 'CAN_UPGRADE': False} GLOBAL_CONTEXT = {} SNAPSHOT_PERMISSION_META = PERMISSIONS_META @@ -72,13 +71,6 @@ class SnapshotActionForm(ActionForm): return [name.strip() for name in tags_str.split(",") if name.strip()] - # TODO: allow selecting actions for specific extractor plugins? is this useful? - # plugin = forms.ChoiceField( - # choices=ArchiveResult.PLUGIN_CHOICES, - # required=False, - # widget=forms.MultileChoiceField(attrs={'class': "form-control"}) - # ) - class TagNameListFilter(admin.SimpleListFilter): title = "By tag name" @@ -748,31 +740,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): html = Template("""{{bookmarked_date}} ({{timestamp}})""") return mark_safe(html.render(context)) - # pretty_time = obj.bookmarked.strftime('%Y-%m-%d %H:%M:%S') - # return f'{pretty_time} ({obj.timestamp})' - - # TODO: figure out a different way to do this, you cant nest forms so this doenst work - # def action(self, obj): - # # csrfmiddlewaretoken: Wa8UcQ4fD3FJibzxqHN3IYrrjLo4VguWynmbzzcPYoebfVUnDovon7GEMYFRgsh0 - # # action: update_snapshots - # # select_across: 0 - # # _selected_action: 76d29b26-2a88-439e-877c-a7cca1b72bb3 - # return format_html( - # ''' - #
- # - # - # - # - # - # - # - #
- # ''', - # csrf.get_token(self.request), - # obj.pk, - # ) - @admin.display(description="") def admin_actions(self, obj): summary_url = self.get_snapshot_view_url(obj) @@ -1149,9 +1116,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): mark_safe("".join(output)), ) - @admin.display( - # ordering='archiveresult_count' - ) + @admin.display() def size(self, obj): request = self.request config = request.archivebox_config @@ -1401,11 +1366,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin): extra_context["snapshot_is_grid_view"] = True return self.changelist_view(request, extra_context=extra_context) - # for debugging, uncomment this to print all requests: - # def changelist_view(self, request, extra_context=None): - # print('[*] Got request', request.method, request.POST) - # return super().changelist_view(request, extra_context=None) - @admin.action( description="🔁 Redo Failed", ) diff --git a/archivebox/core/asgi.py b/archivebox/core/asgi.py index 7f79ffd4..2d52aee9 100644 --- a/archivebox/core/asgi.py +++ b/archivebox/core/asgi.py @@ -73,16 +73,3 @@ _patch_thread_sensitive_context_shutdown() # Standard Django ASGI application (no websockets/channels needed) application = get_asgi_application() - -# If websocket support is needed later, install channels and use: -# from channels.routing import ProtocolTypeRouter, URLRouter -# from channels.auth import AuthMiddlewareStack -# from channels.security.websocket import AllowedHostsOriginValidator -# from archivebox.core.routing import websocket_urlpatterns -# -# application = ProtocolTypeRouter({ -# "http": get_asgi_application(), -# "websocket": AllowedHostsOriginValidator( -# AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) -# ), -# }) diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py index 6a99cea5..2ed6260f 100644 --- a/archivebox/core/middleware.py +++ b/archivebox/core/middleware.py @@ -73,7 +73,6 @@ def detect_timezone(request, activate: bool = True): tz = timezone.get_fixed_timezone(int(gmt_offset)) if activate: timezone.activate(tz) - # print('GMT_OFFSET', gmt_offset, tz) return tz @@ -153,7 +152,6 @@ def CacheControlMiddleware(get_response): request.archivebox_config = config policy = "private" if config.PERMISSIONS == "private" else "public" response["Cache-Control"] = f"{policy}, max-age=60, stale-while-revalidate=300" - # print('Set Cache-Control header to', response['Cache-Control']) return response return middleware diff --git a/archivebox/core/models.py b/archivebox/core/models.py index 6585a026..04fd18e4 100755 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -1032,21 +1032,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW transaction.on_commit(lambda: self._cleanup_old_migration_dir(old_dir, new_dir)) delattr(self, "_pending_fs_migration_cleanup") - # if is_new: - # from archivebox.misc.logging_util import log_worker_event - # log_worker_event( - # worker_type='DB', - # event='Created Snapshot', - # indent_level=2, - # url=self.url, - # metadata={ - # 'id': str(self.id), - # 'crawl_id': str(self.crawl_id), - # 'depth': self.depth, - # 'status': self.status, - # }, - # ) - # ========================================================================= # Filesystem Migration Methods # ========================================================================= @@ -4063,21 +4048,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes): if is_new or update_fields is None or "status" in update_fields or "snapshot" in update_fields or "snapshot_id" in update_fields: transaction.on_commit(type(self).clear_majority_status_cache) - # if is_new: - # from archivebox.misc.logging_util import log_worker_event - # log_worker_event( - # worker_type='DB', - # event='Created ArchiveResult', - # indent_level=3, - # plugin=self.plugin, - # metadata={ - # 'id': str(self.id), - # 'snapshot_id': str(self.snapshot_id), - # 'snapshot_url': str(self.snapshot.url)[:64], - # 'status': self.status, - # }, - # ) - def delete(self, *args, **kwargs): snapshot_id = self.snapshot_id deleted = super().delete(*args, **kwargs) diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index d4e8f877..f3e0370f 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -18,7 +18,7 @@ from .settings_logging import SETTINGS_LOGGING IS_MIGRATING = "makemigrations" in sys.argv[:3] or "migrate" in sys.argv[:3] -IS_TESTING = "test" in sys.argv[:3] or "PYTEST_CURRENT_TEST" in os.environ +IS_TESTING = "test" in sys.argv[:3] IS_SHELL = "shell" in sys.argv[:3] or "shell_plus" in sys.argv[:3] IS_GETTING_VERSION_OR_HELP = "version" in sys.argv or "help" in sys.argv or "--version" in sys.argv or "--help" in sys.argv CONFIG = get_config() @@ -103,7 +103,6 @@ MIDDLEWARE = [ ### Authentication Settings ################################################################################ -# AUTH_USER_MODEL = 'auth.User' # cannot be easily changed unfortunately AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.RemoteUserBackend", @@ -181,23 +180,11 @@ TEMPLATES_DIR_NAME = "templates" CUSTOM_TEMPLATES_ENABLED = os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR) and os.access(CONSTANTS.CUSTOM_TEMPLATES_DIR, os.R_OK) STATICFILES_DIRS = [ *([str(CONSTANTS.CUSTOM_TEMPLATES_DIR / "static")] if CUSTOM_TEMPLATES_ENABLED else []), - # *[ - # str(plugin_dir / 'static') - # for plugin_dir in PLUGIN_DIRS.values() - # if (plugin_dir / 'static').is_dir() - # ], - # Additional static file dirs from plugins str(PACKAGE_DIR / TEMPLATES_DIR_NAME / "static"), ] TEMPLATE_DIRS = [ *([str(CONSTANTS.CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_ENABLED else []), - # *[ - # str(plugin_dir / 'templates') - # for plugin_dir in PLUGIN_DIRS.values() - # if (plugin_dir / 'templates').is_dir() - # ], - # Additional template dirs from plugins str(PACKAGE_DIR / TEMPLATES_DIR_NAME / "core"), str(PACKAGE_DIR / TEMPLATES_DIR_NAME / "admin"), str(PACKAGE_DIR / TEMPLATES_DIR_NAME), @@ -225,10 +212,6 @@ TEMPLATES = [ ### External Service Settings ################################################################################ -# CACHE_DB_FILENAME = 'cache.sqlite3' -# CACHE_DB_PATH = CONSTANTS.CACHE_DIR / CACHE_DB_FILENAME -# CACHE_DB_TABLE = 'django_cache' - DATABASE_NAME = CONFIG.DATABASE_NAME SQLITE_JOURNAL_MODE = CONFIG.SQLITE_JOURNAL_MODE SQLITE_MMAP_SIZE = CONFIG.SQLITE_MMAP_SIZE @@ -267,14 +250,6 @@ DATABASES = { "NAME": DATABASE_NAME, **SQLITE_CONNECTION_OPTIONS, }, - # "filestore": { - # "NAME": CONSTANTS.FILESTORE_DATABASE_FILE, - # **SQLITE_CONNECTION_OPTIONS, - # }, - # 'cache': { - # 'NAME': CACHE_DB_PATH, - # **SQLITE_CONNECTION_OPTIONS, - # }, } MIGRATION_MODULES = {"signal_webhooks": None} @@ -285,43 +260,10 @@ MIGRATION_MODULES = {"signal_webhooks": None} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# class FilestoreDBRouter: -# """ -# A router to store all the File models in the filestore.sqlite3 database. -# This data just mirrors what is in the file system, so we want to keep it in a separate database -# from the main index database to avoid contention. -# """ - -# route_app_labels = {"filestore"} -# db_name = "filestore" - -# def db_for_read(self, model, **hints): -# if model._meta.app_label in self.route_app_labels: -# return self.db_name -# return 'default' - -# def db_for_write(self, model, **hints): -# if model._meta.app_label in self.route_app_labels: -# return self.db_name -# return 'default' - -# def allow_relation(self, obj1, obj2, **hints): -# if obj1._meta.app_label in self.route_app_labels or obj2._meta.app_label in self.route_app_labels: -# return obj1._meta.app_label == obj2._meta.app_label -# return None - -# def allow_migrate(self, db, app_label, model_name=None, **hints): -# if app_label in self.route_app_labels: -# return db == self.db_name -# return db == "default" - DATABASE_ROUTERS = [] CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, - # 'sqlite': {'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 'LOCATION': 'cache'}, - # 'dummy': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}, - # 'filebased': {"BACKEND": "django.core.cache.backends.filebased.FileBasedCache", "LOCATION": CACHE_DIR / 'cache_filebased'}, } EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" @@ -666,16 +608,3 @@ if DEBUG_REQUESTS_TRACKER: r"^SELECT .* FROM django_migrations WHERE app = 'auth'", ), } - -# # https://docs.pydantic.dev/logfire/integrations/django/ (similar to DataDog / NewRelic / etc.) -# DEBUG_LOGFIRE = False -# DEBUG_LOGFIRE = DEBUG_LOGFIRE and os.access(DATA_DIR / '.logfire', os.W_OK) and (DATA_DIR / '.logfire').is_dir() - - -# For usage with https://www.jetadmin.io/integrations/django -# INSTALLED_APPS += ['jet_django'] -# JET_PROJECT = 'archivebox' -# JET_TOKEN = 'some-api-token-here' - - -# import ipdb; ipdb.set_trace() diff --git a/archivebox/core/settings_logging.py b/archivebox/core/settings_logging.py index 4f591db8..100b3c20 100644 --- a/archivebox/core/settings_logging.py +++ b/archivebox/core/settings_logging.py @@ -130,8 +130,6 @@ if os.access(LOGS_DIR, os.W_OK) and LOGS_DIR.is_dir(): ERROR_LOG = LOGS_DIR / "errors.log" else: # historically too many edge cases here around creating log dir w/ correct permissions early on - # if there's an issue on startup, we trash the log and let user figure it out via stdout/stderr - # print(f'[!] WARNING: data/logs dir does not exist. Logging to temp file: {ERROR_LOG}') pass LOG_LEVEL_DATABASE = "WARNING" # change to DEBUG to log all SQL queries diff --git a/archivebox/core/urls.py b/archivebox/core/urls.py index 2b51baad..1462b7dd 100644 --- a/archivebox/core/urls.py +++ b/archivebox/core/urls.py @@ -30,12 +30,6 @@ from archivebox.progressmonitor.views import live_progress_view from archivebox.search.views import public_snapshot_search_stream_view from abx_plugins.plugins.opencode.views import opencode_proxy_view - -# GLOBAL_CONTEXT doesn't work as-is, disabled for now: https://github.com/ArchiveBox/ArchiveBox/discussions/1306 -# from archivebox.config import VERSION, VERSIONS_AVAILABLE, CAN_UPGRADE -# GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': VERSIONS_AVAILABLE, 'CAN_UPGRADE': CAN_UPGRADE} - - CONFIG = get_config() DEBUG = CONFIG.DEBUG or ("--debug" in sys.argv) @@ -86,7 +80,6 @@ urlpatterns = [ path("api/", include("archivebox.api.urls"), name="api"), path("health/", HealthCheckView.as_view(), name="healthcheck"), path("error/", lambda request: _raise_test_error(request)), - # path('jet_api/', include('jet_django.urls')), Enable to use https://www.jetadmin.io/integrations/django path("index.html", RedirectView.as_view(url="/")), path("", HomepageView.as_view(), name="Home"), ] @@ -101,36 +94,3 @@ if getattr(settings, "DEBUG_TOOLBAR", False): if getattr(settings, "DEBUG_REQUESTS_TRACKER", False) and find_spec("requests_tracker"): urlpatterns += [path("__requests_tracker__/", include("requests_tracker.urls"))] - - -# # Proposed FUTURE URLs spec -# path('', HomepageView) -# path('/add', AddView) -# path('/public', PublicIndexView) -# path('/snapshot/:slug', SnapshotView) - -# path('/admin', admin.site.urls) -# path('/accounts', django.contrib.auth.urls) - -# # Proposed REST API spec -# # :slugs can be uuid, short_uuid, or any of the unique index_fields -# path('api/v1/'), -# path('api/v1/core/' [GET]) -# path('api/v1/core/snapshot/', [GET, POST, PUT]), -# path('api/v1/core/snapshot/:slug', [GET, PATCH, DELETE]), -# path('api/v1/core/archiveresult', [GET, POST, PUT]), -# path('api/v1/core/archiveresult/:slug', [GET, PATCH, DELETE]), -# path('api/v1/core/tag/', [GET, POST, PUT]), -# path('api/v1/core/tag/:slug', [GET, PATCH, DELETE]), - -# path('api/v1/cli/', [GET]) -# path('api/v1/cli/{add,list,config,...}', [POST]), # pass query as kwargs directly to `run_subcommand` and return stdout, stderr, exitcode - -# path('api/v1/extractors/', [GET]) -# path('api/v1/extractors/:extractor/', [GET]), -# path('api/v1/extractors/:extractor/:func', [GET, POST]), # pass query as args directly to chosen function - -# future, just an idea: -# path('api/v1/scheduler/', [GET]) -# path('api/v1/scheduler/task/', [GET, POST, PUT]), -# path('api/v1/scheduler/task/:slug', [GET, PATCH, DELETE]), diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 8e3299c1..d9f4bfe9 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -1746,8 +1746,6 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: find_config_default(key) or "See here...", ), ) - # rows['Documentation'].append(mark_safe(f'Wiki: {key}')) - # rows['Aliases'].append(', '.join(find_config_aliases(key))) section = "CONSTANT" for key in CONSTANTS_CONFIG.keys(): @@ -1763,8 +1761,6 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: find_config_default(key) or "See here...", ), ) - # rows['Documentation'].append(mark_safe(f'Wiki: {key}')) - # rows['Aliases'].append('') return TableContext( title="Computed Configuration Values", @@ -1820,7 +1816,6 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont ((color, source, value) for source, value, color in sources_info), ) - # aliases = USER_CONFIG.get(key, {}).get("aliases", []) aliases = [] if key in CONSTANTS_CONFIG: diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py index c8461f54..fe262135 100755 --- a/archivebox/crawls/models.py +++ b/archivebox/crawls/models.py @@ -209,7 +209,13 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith return resolve_delete_after_config_value(self.config) def pause(self, *, save: bool = True) -> bool: - return super().pause(save=save) + paused = super().pause(save=save) + if paused and save and self.pk: + from archivebox.core.models import Snapshot + + for snapshot in self.snapshot_set.exclude(status__in=Snapshot.FINAL_STATES).iterator(): + snapshot.pause() + return paused def resume(self, *, when=None, save: bool = True) -> bool: resumed = super().resume(when=when, save=save) @@ -338,20 +344,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith removed_tag_names=removed_tag_names, ), ) - # if is_new: - # from archivebox.misc.logging_util import log_worker_event - # first_url = self.get_urls_list()[0] if self.get_urls_list() else '' - # log_worker_event( - # worker_type='DB', - # event='Created Crawl', - # indent_level=1, - # metadata={ - # 'id': str(self.id), - # 'first_url': first_url[:64], - # 'max_depth': self.max_depth, - # 'status': self.status, - # }, - # ) def update_child_snapshot_permissions(self, old_permissions: str | None, new_permissions: str | None) -> int: from archivebox.core.models import Snapshot diff --git a/archivebox/machine/detect.py b/archivebox/machine/detect.py index cf635e46..d73cf9a6 100644 --- a/archivebox/machine/detect.py +++ b/archivebox/machine/detect.py @@ -18,6 +18,24 @@ PACKAGE_DIR = Path(__file__).parent DATA_DIR = Path(os.getcwd()).resolve() +def _run_abxpkg_host_binary(name: str, *args: str) -> subprocess.CompletedProcess[str]: + from abxpkg import EnvProvider + from archivebox.config.constants import CONSTANTS + + provider = EnvProvider(install_root=CONSTANTS.DEFAULT_ABXPKG_LIB_DIR / "env", PATH=os.environ["PATH"]) + if name == "system_profiler": + provider = provider.get_provider_with_overrides( + overrides={name: {"version": platform.mac_ver()[0] or "0.0.0"}}, + ) + loaded = provider.load(name) + if loaded is None or loaded.loaded_abspath is None: + raise RuntimeError(f"abxpkg could not resolve {name}") + projection = Path(loaded.loaded_abspath) + if not projection.is_symlink() or not os.access(projection, os.X_OK): + raise RuntimeError(f"abxpkg did not project {name} into {projection}") + return subprocess.run([str(projection), *args], capture_output=True, text=True, check=True) + + def get_vm_info(): hw_in_docker = bool(os.getenv("IN_DOCKER", False) in ("1", "true", "True", "TRUE")) hw_in_vm = False @@ -49,7 +67,7 @@ def get_vm_info(): # Serial Number (system): M230YYTD77 # Hardware UUID: 39A12B50-1972-5910-8BEE-235AD20C8EE3 # ... - result = subprocess.run(["system_profiler", "SPHardwareDataType"], capture_output=True, text=True, check=True) + result = _run_abxpkg_host_binary("system_profiler", "SPHardwareDataType") for line in result.stdout.split("\n"): if "Model Name:" in line: hw_product = line.split(":", 1)[-1].strip() @@ -73,7 +91,7 @@ def get_vm_info(): # UUID: fb65f41c-ec24-4539-beaf-f941903bdb2c # ... # Family: DigitalOcean_Droplet - dmidecode = subprocess.run(["dmidecode", "-t", "system"], capture_output=True, text=True, check=True) + dmidecode = _run_abxpkg_host_binary("dmidecode", "-t", "system") for line in dmidecode.stdout.split("\n"): if "Manufacturer:" in line: hw_manufacturer = line.split(":", 1)[-1].strip() @@ -90,7 +108,7 @@ def get_vm_info(): # Check for QEMU explicitly in pmap output try: - result = subprocess.run(["pmap", "1"], capture_output=True, text=True, check=True) + result = _run_abxpkg_host_binary("pmap", "1") if "qemu" in result.stdout.lower(): hw_in_vm = True except Exception: @@ -174,7 +192,7 @@ def get_isp_info(ip=None): # Get system DNS resolver servers dns_server = None try: - result = subprocess.run(["dig", "example.com", "A"], capture_output=True, text=True, check=True).stdout + result = _run_abxpkg_host_binary("dig", "example.com", "A").stdout dns_server = result.split(";; SERVER: ", 1)[-1].split("\n")[0].split("#")[0].strip() except Exception: try: @@ -183,10 +201,6 @@ def get_isp_info(ip=None): dns_server = "127.0.0.1" print(f"[red]:warning: WARNING: Could not determine DNS server, using {dns_server}[/red]") - # Get DNS resolver's ISP name - # url = f'https://ipapi.co/{dns_server}/json/' - # dns_isp = json.loads(urllib.request.urlopen(url).read().decode()).get('org', 'Unknown') - return { "isp": isp, "city": city, @@ -225,7 +239,7 @@ def get_os_info() -> dict[str, Any]: os_release = "macOS " + platform.mac_ver()[0] else: try: - os_release = subprocess.run(["lsb_release", "-ds"], capture_output=True, text=True, check=True).stdout.strip() + os_release = _run_abxpkg_host_binary("lsb_release", "-ds").stdout.strip() except Exception: pass diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py index 253dfc2e..5d5b6ec2 100755 --- a/archivebox/machine/models.py +++ b/archivebox/machine/models.py @@ -400,9 +400,6 @@ class NetworkInterface(ModelWithHealthStats): city = models.CharField(max_length=63, default="", null=False) region = models.CharField(max_length=63, default="", null=False) country = models.CharField(max_length=63, default="", null=False) - # num_uses_failed = models.PositiveIntegerField(default=0) # from ModelWithHealthStats - # num_uses_succeeded = models.PositiveIntegerField(default=0) # from ModelWithHealthStats - objects = NetworkInterfaceManager() # pyright: ignore[reportIncompatibleVariableOverride] machine_id: uuid.UUID @@ -1493,15 +1490,11 @@ class Process(ModelWithDeleteAfter, models.Model): ppid = os.getppid() machine = machine or Machine.current() - # Debug logging - # print(f"DEBUG _find_parent_process: my_pid={os.getpid()}, ppid={ppid}", file=sys.stderr) - # Get parent process start time from OS try: os_parent = psutil.Process(ppid) os_parent_start = os_parent.create_time() except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - # print(f"DEBUG _find_parent_process: Parent process {ppid} not accessible", file=sys.stderr) return None # Parent process doesn't exist # Find matching Process record @@ -1512,18 +1505,13 @@ class Process(ModelWithDeleteAfter, models.Model): started_at__gte=timezone.now() - PID_REUSE_WINDOW, ).order_by("-started_at") - # print(f"DEBUG _find_parent_process: Found {candidates.count()} candidates for ppid={ppid}", file=sys.stderr) - for candidate in candidates: if candidate.started_at: db_start_time = candidate.started_at.timestamp() time_diff = abs(db_start_time - os_parent_start) - # print(f"DEBUG _find_parent_process: Checking candidate id={candidate.id} time_diff={time_diff:.2f}s tolerance={START_TIME_TOLERANCE}s", file=sys.stderr) if time_diff < START_TIME_TOLERANCE: - # print(f"DEBUG _find_parent_process: MATCH! Returning parent id={candidate.id} pid={candidate.pid}", file=sys.stderr) return candidate - # print(f"DEBUG _find_parent_process: No matching parent found for ppid={ppid}", file=sys.stderr) return None # No matching ArchiveBox parent process @classmethod @@ -2142,12 +2130,8 @@ class Process(ModelWithDeleteAfter, models.Model): # Process exited - read output and copy to DB if self.stdout_file and self.stdout_file.exists(): self.stdout = self.stdout_file.read_text(errors="replace") - # TODO: Uncomment to cleanup (keeping for debugging for now) - # self.stdout_file.unlink(missing_ok=True) if self.stderr_file and self.stderr_file.exists(): self.stderr = self.stderr_file.read_text(errors="replace") - # TODO: Uncomment to cleanup (keeping for debugging for now) - # self.stderr_file.unlink(missing_ok=True) self.exit_code = self.exit_code if self.exit_code is not None else _default_exit_code_for_unowned_process(self.process_type) if self.exit_code == -1: @@ -2462,6 +2446,15 @@ class Process(ModelWithDeleteAfter, models.Model): if not chrome_utils.exists(): return 0 + node_binary = cast(BinaryManager, Binary.objects).get_valid_binary("node") + if node_binary is None or not node_binary.is_valid: + return 0 + from archivebox.config.common import get_config + + node_projection = get_config().ABXPKG_LIB_DIR / "env" / "bin" / "node" + if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK): + return 0 + crawl_roots = [ crawls_dir for user_dir in CONSTANTS.USERS_DIR.iterdir() @@ -2476,7 +2469,7 @@ class Process(ModelWithDeleteAfter, models.Model): try: for crawl_root in crawl_roots: result = subprocess.run( - ["node", str(chrome_utils), "killZombieChrome", str(crawl_root)], + [str(node_projection), str(chrome_utils), "killZombieChrome", str(crawl_root)], capture_output=True, timeout=30, text=True, diff --git a/archivebox/misc/checks.py b/archivebox/misc/checks.py index b4a32f52..3234569e 100644 --- a/archivebox/misc/checks.py +++ b/archivebox/misc/checks.py @@ -85,7 +85,6 @@ def check_data_folder(config=None, **config_kwargs) -> None: create_and_chown_dir(CONSTANTS.USERS_DIR) create_and_chown_dir(CONSTANTS.PERSONAS_DIR / "Default") create_and_chown_dir(CONSTANTS.LOGS_DIR) - # create_and_chown_dir(CONSTANTS.CACHE_DIR) # Create /tmp and /lib dirs if they don't exist get_or_create_working_tmp_dir(autofix=True, quiet=False, config=config) @@ -182,18 +181,6 @@ def check_io_encoding(): print(' python3 -c "import sys; print(sys.stdout.encoding)" # should output UTF-8', file=sys.stderr) raise SystemExit(2) - # # hard errors: check python version - # if sys.version_info[:3] < (3, 10, 0): - # print('[red][X] Python version is not new enough: {sys.version} (>3.10 is required)[/red]', file=sys.stderr) - # print(' See https://github.com/ArchiveBox/ArchiveBox/wiki/Troubleshooting#python for help upgrading your Python installation.', file=sys.stderr) - # raise SystemExit(2) - - # # hard errors: check django version - # if int(django.VERSION[0]) < 5: - # print('[red][X] Django version is not new enough: {django.VERSION[:3]} (>=5.0 is required)[/red]', file=sys.stderr) - # print(' Upgrade django using pip or your system package manager: pip3 install --upgrade django', file=sys.stderr) - # raise SystemExit(2) - def check_not_root(): from archivebox.config.permissions import IS_ROOT @@ -227,7 +214,6 @@ def check_data_dir_permissions(config=None, **config_kwargs): data_dir_uid, data_dir_gid = data_dir_stat.st_uid, data_dir_stat.st_gid data_owned_by_root = data_dir_uid == 0 - # data_owned_by_default_user = data_dir_uid == DEFAULT_UID or data_dir_gid == DEFAULT_GID data_owner_doesnt_match = (data_dir_uid != ARCHIVEBOX_USER and data_dir_gid != ARCHIVEBOX_GROUP) if not IS_ROOT else False data_not_writable = not (os.path.isdir(DATA_DIR) and os.access(DATA_DIR, os.W_OK)) if data_not_writable: diff --git a/archivebox/misc/logging_util.py b/archivebox/misc/logging_util.py index ac4b2718..a14ac3b5 100644 --- a/archivebox/misc/logging_util.py +++ b/archivebox/misc/logging_util.py @@ -132,10 +132,6 @@ def progress_bar(seconds: int, prefix: str = "", ANSI: dict[str, str] = ANSI, co ), ) sys.stdout.flush() - # uncomment to have it disappear when it hits 100% instead of staying full red: - # time.sleep(0.5) - # sys.stdout.write('\r{}{}\r'.format((' ' * get_config().TERM_WIDTH), ANSI['reset'])) - # sys.stdout.flush() except (KeyboardInterrupt, BrokenPipeError): print() @@ -148,9 +144,6 @@ def log_cli_command(subcommand: str, subcommand_args: Iterable[str] = (), stdin: subcommand=subcommand, args=args, ) - # stderr() - # stderr('[bright_black] > {pwd}[/]'.format(pwd=pwd, **ANSI)) - # stderr() print(Panel(version_msg), file=sys.stderr) diff --git a/archivebox/misc/monkey_patches.py b/archivebox/misc/monkey_patches.py index a2f99e32..89d858ef 100644 --- a/archivebox/misc/monkey_patches.py +++ b/archivebox/misc/monkey_patches.py @@ -15,17 +15,6 @@ django_stubs_ext.monkeypatch() # monkey patch django timezone to add back utc (it was removed in Django 5.0) setattr(timezone, "utc", datetime.UTC) -# monkey patch django-signals-webhooks to change how it shows up in Admin UI -# from signal_webhooks.apps import DjangoSignalWebhooksConfig -# DjangoSignalWebhooksConfig.verbose_name = 'API' - - -# Rich traceback handler disabled - it adds frames/boxes that wrap weirdly in log files -# Standard Python tracebacks are used instead (full width, no frames) -# from rich.traceback import install -# install(show_locals=True, word_wrap=False, ...) - - # Hide site-packages/sonic/client.py:115: SyntaxWarning # https://github.com/xmonader/python-sonic-client/pull/18 warnings.filterwarnings("ignore", category=SyntaxWarning, module="sonic") diff --git a/archivebox/misc/system.py b/archivebox/misc/system.py index 13e937db..b02ec4c5 100644 --- a/archivebox/misc/system.py +++ b/archivebox/misc/system.py @@ -22,7 +22,6 @@ def atomic_write(path: Path | str, contents: dict | str | bytes, overwrite: bool mode = "wb+" if isinstance(contents, bytes) else "w" encoding = None if isinstance(contents, bytes) else "utf-8" # enforce utf-8 on all text writes - # print('\n> Atomic Write:', mode, path, len(contents), f'overwrite={overwrite}') try: with lib_atomic_write(path, mode=mode, overwrite=overwrite, encoding=encoding) as f: if isinstance(contents, dict): diff --git a/archivebox/misc/toml_util.py b/archivebox/misc/toml_util.py index 33daaa40..457db420 100644 --- a/archivebox/misc/toml_util.py +++ b/archivebox/misc/toml_util.py @@ -90,9 +90,6 @@ class JSONSchemaWithLambdas(GenerateJsonSchema): serialize_unknown=True, ) - # for computed_field properties render them like this instead: - # inspect.getsource(field.wrapped_property.fget).split('def ', 1)[-1].split('\n', 1)[-1].strip().strip('return '), - def better_toml_dump_str(val: Any) -> str: try: diff --git a/archivebox/personas/importers.py b/archivebox/personas/importers.py index b55a3d89..2b172493 100644 --- a/archivebox/personas/importers.py +++ b/archivebox/personas/importers.py @@ -634,7 +634,12 @@ def export_browser_state( return False, None, "abxpkg returned an invalid browser dependency environment." if not isinstance(resolved_env, dict): return False, None, "abxpkg returned an invalid browser dependency environment." + abxpkg_lib_dir = get_config().ABXPKG_LIB_DIR + node_projection = abxpkg_lib_dir / "env" / "bin" / "node" + if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK): + return False, None, f"abxpkg did not resolve Node.js into {node_projection}." env.update({str(key): str(value) for key, value in resolved_env.items()}) + env["NODE_BINARY"] = str(node_projection) env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(chrome_plugin_dir) if user_data_dir: @@ -683,7 +688,7 @@ def export_browser_state( try: result = subprocess.run( - ["node", str(state_script)], + [str(node_projection), str(state_script)], env=env, capture_output=True, text=True, diff --git a/archivebox/plugins/hooks.py b/archivebox/plugins/hooks.py index f75d5b68..eeeaa6d2 100644 --- a/archivebox/plugins/hooks.py +++ b/archivebox/plugins/hooks.py @@ -323,21 +323,8 @@ def run_hook( # If Process.current() fails (e.g., not in a worker context), leave parent as None pass - if not script.exists(): - # Create a failed Process record for hooks that don't exist - process = Process.objects.create( - machine=machine, - iface=iface, - parent=parent, - process_type=Process.TypeChoices.HOOK, - pwd=str(output_dir), - cmd=["echo", f"Hook script not found: {script}"], - timeout=timeout, - status=Process.StatusChoices.EXITED, - exit_code=1, - stderr=f"Hook script not found: {script}", - ) - return process + if not script.is_file(): + raise FileNotFoundError(f"Hook script not found: {script}") # Python hooks carry their runtime contract in the shebang # (usually `abxpkg run --script python3`), so execute them directly. @@ -345,11 +332,18 @@ def run_hook( # interpreter because those hooks do not need per-script Python env setup. ext = script.suffix.lower() if ext == ".sh": - cmd = ["bash", str(script)] + bash_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "bash" + if not bash_projection.is_symlink() or not os.access(bash_projection, os.X_OK): + raise RuntimeError(f"Bash must be resolved by abxpkg into {bash_projection}") + cmd = [str(bash_projection), str(script)] elif ext == ".py": cmd = [str(script)] elif ext == ".js": - cmd = ["node", str(script)] + node_projection = Path(hook_config["ABXPKG_LIB_DIR"]).expanduser() / "env" / "bin" / "node" + if not node_projection.is_symlink() or not os.access(node_projection, os.X_OK): + raise RuntimeError(f"Node.js must be resolved by abxpkg into {node_projection}") + hook_config["NODE_BINARY"] = str(node_projection) + cmd = [str(node_projection), str(script)] else: # Try to execute directly (assumes shebang) cmd = [str(script)] @@ -510,10 +504,12 @@ def extract_records_from_process(process: "Process") -> list[dict[str, Any]]: if not records: return [] - # Extract plugin metadata from process.pwd and process.cmd + # Extract plugin metadata from process.pwd and the shipped hook path in cmd. + # Python hooks execute directly through their shebang, while JS and shell + # hooks have an interpreter in cmd[0]. plugin_name = Path(process.pwd).name if process.pwd else "unknown" - hook_name = Path(process.cmd[1]).name if len(process.cmd) > 1 else "unknown" - plugin_hook = process.cmd[1] if len(process.cmd) > 1 else "" + plugin_hook = next((str(arg) for arg in process.cmd if Path(str(arg)).name.startswith("on_")), "") + hook_name = Path(plugin_hook).name if plugin_hook else "unknown" for record in records: # Add plugin metadata to record diff --git a/archivebox/progressmonitor/views.py b/archivebox/progressmonitor/views.py index daba7a2d..d1d4c0ef 100644 --- a/archivebox/progressmonitor/views.py +++ b/archivebox/progressmonitor/views.py @@ -133,9 +133,12 @@ def live_progress_view(request): def process_label(cmd: list[str] | None) -> tuple[str, str, str, str]: hook_path = "" if isinstance(cmd, list) and cmd: - first = cmd[0] - if isinstance(first, str): - hook_path = first + hook_path = next( + (arg for arg in cmd if isinstance(arg, str) and Path(arg).name.startswith("on_") and "__" in Path(arg).name), + "", + ) + if not hook_path and isinstance(cmd[0], str): + hook_path = cmd[0] if not hook_path: return ("", "setup", "unknown", "") diff --git a/archivebox/services/archive_result_service.py b/archivebox/services/archive_result_service.py index cec3be70..0ed9ff31 100644 --- a/archivebox/services/archive_result_service.py +++ b/archivebox/services/archive_result_service.py @@ -381,6 +381,40 @@ def _save_archiveresult_event_to_db( snapshot.title = next_title snapshot.save(update_fields=["title", "modified_at"]) + # Parser output becomes durable when its ArchiveResult reaches a final + # state. Project at that same lifecycle boundary so every completed parser + # can enrich an already-discovered URL before Snapshot/Crawl completion. + # create_discovered_snapshots() owns cross-parser dedupe and metadata merge. + if ( + result.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS) + and (plugin_dir / "urls.jsonl").exists() + ): + from .snapshot_service import project_discovered_snapshots + + with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.project_discovered_snapshots"): + project_discovered_snapshots(str(snapshot.id)) + + +def mark_archiveresult_started(event: ProcessStartedEvent, *, snapshot_id: str, process_id: str) -> None: + """Advance an existing queued hook row after its OS process is persisted.""" + from archivebox.core.models import ArchiveResult + + started_at = parse_event_datetime(event.start_ts) + if started_at is None: + raise ValueError("ProcessStartedEvent.start_ts is required") + ArchiveResult.objects.filter( + snapshot_id=snapshot_id, + plugin=event.plugin_name, + hook_name=event.hook_name, + status=ArchiveResult.StatusChoices.QUEUED, + ).update( + status=ArchiveResult.StatusChoices.STARTED, + start_ts=started_at, + end_ts=None, + process_id=process_id, + modified_at=timezone.now(), + ) + class ArchiveResultService(BaseService): LISTENS_TO = [ArchiveResultEvent, ProcessCompletedEvent] diff --git a/archivebox/services/process_service.py b/archivebox/services/process_service.py index 17353c00..9be179d5 100644 --- a/archivebox/services/process_service.py +++ b/archivebox/services/process_service.py @@ -8,7 +8,7 @@ from asgiref.sync import sync_to_async from django.utils import timezone from abxbus import BaseEvent -from abx_dl.events import CrawlCleanupEvent, CrawlCompletedEvent, ProcessCompletedEvent, ProcessStartedEvent +from abx_dl.events import CrawlCleanupEvent, CrawlCompletedEvent, ProcessCompletedEvent, ProcessStartedEvent, SnapshotEvent from abx_dl.services.base import BaseService @@ -147,6 +147,21 @@ class ProcessService(BaseService): binary_id=process.binary_id, modified_at=timezone.now(), ) + if event.hook_name.startswith("on_Snapshot"): + snapshot_event = await self.bus.find( + SnapshotEvent, + past=True, + future=False, + where=lambda candidate: self.bus.event_is_child_of(event, candidate), + ) + if snapshot_event is not None: + from .archive_result_service import mark_archiveresult_started + + await sync_to_async(mark_archiveresult_started, thread_sensitive=True)( + event, + snapshot_id=snapshot_event.snapshot_id, + process_id=str(process.id), + ) async def _completed_worker_loop(self) -> None: while True: diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py index 13e68b69..02729bae 100644 --- a/archivebox/services/runner.py +++ b/archivebox/services/runner.py @@ -180,10 +180,7 @@ async def _run_event_now(event, timeout: float | None = None): return event -def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool: - if os.environ.get("PYTEST_CURRENT_TEST") and not allow_under_pytest: - return False - +def ensure_background_runner() -> bool: from archivebox.machine.models import Machine, Process from archivebox.workers.supervisord_util import RUNNER_WORKER, get_existing_supervisord_process, get_worker, start_worker @@ -192,7 +189,7 @@ def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool: if runner_worker and runner_worker.get("statename") in ("STARTING", "RUNNING"): return False if supervisor is not None: - start_worker(supervisor, RUNNER_WORKER) + start_worker(supervisor, RUNNER_WORKER()) return True machine = Machine.current() @@ -238,13 +235,9 @@ class CrawlRunner: self.interactive_interrupts = interactive_interrupts self.config_overrides = dict(config_overrides or {}) - async def ignore_snapshot(_snapshot_id: str) -> None: - return None - SnapshotService( self.bus, crawl_id=str(crawl.id), - schedule_snapshot=self.enqueue_snapshot if process_discovered_snapshots_inline else ignore_snapshot, ) ArchiveResultService(self.bus) self.selected_plugins = selected_plugins diff --git a/archivebox/services/snapshot_service.py b/archivebox/services/snapshot_service.py index 1934596d..5f3148ce 100644 --- a/archivebox/services/snapshot_service.py +++ b/archivebox/services/snapshot_service.py @@ -98,9 +98,8 @@ class SnapshotService(BaseService): LISTENS_TO = [SnapshotEvent, SnapshotCompletedEvent] EMITS = [] - def __init__(self, bus, *, crawl_id: str, schedule_snapshot): + def __init__(self, bus, *, crawl_id: str): self.crawl_id = crawl_id - self.schedule_snapshot = schedule_snapshot super().__init__(bus) self.bus.on(SnapshotEvent, self.on_SnapshotEvent) self.bus.on(SnapshotCompletedEvent, self.on_SnapshotCompletedEvent) diff --git a/archivebox/tests/conftest.py b/archivebox/tests/conftest.py index 413a0f90..4d885078 100644 --- a/archivebox/tests/conftest.py +++ b/archivebox/tests/conftest.py @@ -5,6 +5,7 @@ import json import re import secrets import signal +import select import socket import subprocess import sys @@ -12,10 +13,11 @@ import tempfile import textwrap import time import shutil +import ctypes from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from threading import Thread +from threading import Event, Thread from types import SimpleNamespace from typing import Any from collections.abc import Callable @@ -41,6 +43,8 @@ os.environ.pop("USERS_DIR", None) os.environ.pop("CRAWL_DIR", None) os.environ.pop("SNAP_DIR", None) +_RUNTIME_GUARD_ACTIVE = False + def _is_repo_path(path: Path) -> bool: resolved = path.expanduser().resolve(strict=False) @@ -120,6 +124,25 @@ def _sync_archivebox_test_data_dir(data_dir: Path) -> None: ) +def _archivebox_test_audit_hook(event: str, args: tuple[Any, ...]) -> None: + """Enforce runtime path isolation through Python's native audit surface.""" + if not _RUNTIME_GUARD_ACTIVE: + return + if event == "os.chdir": + path = Path(args[0]) + _assert_not_repo_path(path, label="cwd") + _sync_archivebox_test_data_dir(path) + elif event == "subprocess.Popen": + cwd = args[2] + env = args[3] + if cwd is not None: + _assert_not_repo_path(Path(cwd), label="cwd") + _assert_safe_runtime_paths(cwd=Path(cwd) if cwd is not None else None, env=env) + + +sys.addaudithook(_archivebox_test_audit_hook) + + # ============================================================================= # CLI Helpers (defined before fixtures that use them) # ============================================================================= @@ -171,9 +194,6 @@ class ArchiveBoxCmdResult: def terminate(self) -> None: self._process.terminate() - def kill(self) -> None: - self._process.kill() - def send_signal(self, sig: int) -> None: self._process.send_signal(sig) @@ -263,8 +283,8 @@ def run_archivebox_cmd( try: result.communicate(input=input, timeout=timeout) except subprocess.TimeoutExpired: - process.kill() - result.communicate() + process.terminate() + process.wait(timeout=5) raise if check and result.returncode: raise subprocess.CalledProcessError( @@ -305,7 +325,7 @@ def pytest_configure(): @pytest.fixture(autouse=True) -def isolate_test_runtime(tmp_path, monkeypatch): +def isolate_test_runtime(tmp_path): """ Run each pytest test from an isolated temp cwd and restore env mutations. @@ -317,11 +337,11 @@ def isolate_test_runtime(tmp_path, monkeypatch): ArchiveBox derives DATA_DIR from cwd, so subprocess helpers pass the target collection as cwd instead of using DATA_DIR as an override. """ + global _RUNTIME_GUARD_ACTIVE + _assert_not_repo_path(tmp_path, label="tmp_path") original_cwd = Path.cwd() original_env = os.environ.copy() - original_chdir = os.chdir - original_popen = subprocess.Popen os.chdir(tmp_path) _sync_archivebox_test_data_dir(tmp_path) os.environ.pop("DATA_DIR", None) @@ -334,28 +354,15 @@ def isolate_test_runtime(tmp_path, monkeypatch): machine_models._CURRENT_PROCESS = None machine_models._CURRENT_BINARIES.clear() - def guarded_chdir(path: os.PathLike[str] | str) -> None: - _assert_not_repo_path(Path(path), label="cwd") - original_chdir(path) - _sync_archivebox_test_data_dir(Path(path)) - - def guarded_popen(*args: Any, **kwargs: Any): - cwd = kwargs.get("cwd") - env = kwargs.get("env") - if cwd is not None: - _assert_not_repo_path(Path(cwd), label="cwd") - _assert_safe_runtime_paths(cwd=Path(cwd) if cwd is not None else None, env=env) - return original_popen(*args, **kwargs) - - monkeypatch.setattr(os, "chdir", guarded_chdir) - monkeypatch.setattr(subprocess, "Popen", guarded_popen) reset_machine_model_caches() + _RUNTIME_GUARD_ACTIVE = True try: _assert_safe_runtime_paths(cwd=Path.cwd(), env=os.environ) yield finally: + _RUNTIME_GUARD_ACTIVE = False reset_machine_model_caches() - original_chdir(original_cwd) + os.chdir(original_cwd) _sync_archivebox_test_data_dir(original_cwd) os.environ.clear() os.environ.update(original_env) @@ -378,7 +385,7 @@ def isolated_data_dir(tmp_path): @pytest.fixture -def hermetic_lib_dir(tmp_path, monkeypatch): +def hermetic_lib_dir(tmp_path): """ Point ABXPKG_LIB_DIR at a temporary directory for isolated abxpkg resolution. @@ -390,10 +397,27 @@ def hermetic_lib_dir(tmp_path, monkeypatch): lib_dir = tmp_path / "lib" lib_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("ABXPKG_LIB_DIR", str(lib_dir)) + original_lib_dir = os.environ.get("ABXPKG_LIB_DIR") + os.environ["ABXPKG_LIB_DIR"] = str(lib_dir) machine_models._CURRENT_MACHINE = None machine_models._CURRENT_PROCESS = None - return lib_dir + try: + yield lib_dir + finally: + if original_lib_dir is None: + os.environ.pop("ABXPKG_LIB_DIR", None) + else: + os.environ["ABXPKG_LIB_DIR"] = original_lib_dir + machine_models._CURRENT_MACHINE = None + machine_models._CURRENT_PROCESS = None + + +@pytest.fixture +def cached_abxpkg_lib_dir(): + """Reuse the configured abxpkg cache when a test is not validating LIB_DIR isolation.""" + from archivebox.config.common import get_config + + return get_config().ABXPKG_LIB_DIR @pytest.fixture @@ -509,8 +533,43 @@ def recursive_test_site(): } finally: server.shutdown() - server.server_close() - thread.join(timeout=5) + server.server_close() + thread.join() + + +@pytest.fixture +def blocking_http_server(): + """Serve one real request behind explicit start/release synchronization.""" + + request_started = Event() + release_response = Event() + + class BlockingHandler(BaseHTTPRequestHandler): + def do_GET(self): + request_started.set() + release_response.wait() + body = b"Barrierreleased" + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), BlockingHandler) + thread = Thread(target=server.serve_forever, name="archivebox-test-http-barrier") + thread.start() + yield SimpleNamespace( + url=f"http://127.0.0.1:{server.server_port}/", + request_started=request_started, + release_response=release_response, + ) + release_response.set() + server.shutdown() + server.server_close() + thread.join() @pytest.fixture @@ -555,21 +614,18 @@ def archivebox_daemon_server(initialized_archive, free_tcp_port_factory): _stop_archivebox_supervisord(cwd, env) -def wait_for_process(predicate: Callable[[psutil.Process, str], bool], *, timeout: float = 20.0) -> psutil.Process: - deadline = time.time() + timeout +def find_process(predicate: Callable[[psutil.Process, str], bool]) -> psutil.Process: + """Locate a process after its native readiness signal has fired.""" last_seen: list[str] = [] - while time.time() < deadline: - last_seen = [] - for proc in psutil.process_iter(["pid", "ppid", "cmdline"]): - try: - cmdline = proc.info.get("cmdline") or [] - command = " ".join(cmdline) - last_seen.append(f"{proc.info.get('pid')} {proc.info.get('ppid')} {command}") - if predicate(proc, command): - return proc - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue - time.sleep(0.2) + for proc in psutil.process_iter(["pid", "ppid", "cmdline"]): + try: + cmdline = proc.info.get("cmdline") or [] + command = " ".join(cmdline) + last_seen.append(f"{proc.info.get('pid')} {proc.info.get('ppid')} {command}") + if predicate(proc, command): + return proc + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue raise AssertionError("No matching live process found. Last seen:\n" + "\n".join(last_seen[-50:])) @@ -583,31 +639,37 @@ def pid_is_alive(pid: int) -> bool: def wait_for_pid_to_disappear(pid: int, *, timeout: float = 20.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - if not pid_is_alive(pid): - return - time.sleep(0.1) - raise AssertionError(f"PID {pid} is still running") + try: + psutil.Process(pid).wait(timeout=timeout) + except psutil.NoSuchProcess: + return + except psutil.TimeoutExpired as exc: + raise AssertionError(f"PID {pid} is still running") from exc def cleanup_process_group(group_pid: int | None, *child_pids: int | None) -> None: + processes: list[psutil.Process] = [] if group_pid and pid_is_alive(group_pid): try: - os.killpg(group_pid, signal.SIGKILL) + os.killpg(group_pid, signal.SIGTERM) except ProcessLookupError: pass except OSError: try: - os.kill(group_pid, signal.SIGKILL) + os.kill(group_pid, signal.SIGTERM) except ProcessLookupError: pass + processes.append(psutil.Process(group_pid)) for pid in child_pids: if pid and pid_is_alive(pid): try: - os.kill(pid, signal.SIGKILL) + os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass + else: + processes.append(psutil.Process(pid)) + _gone, alive = psutil.wait_procs(processes, timeout=10) + assert not alive, f"processes did not stop after SIGTERM: {[proc.pid for proc in alive]}" def cli_env( @@ -685,52 +747,142 @@ def cli_env( return env -def wait_for_port_open(host: str, port: int, *, timeout: float = 30.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - try: - with socket.create_connection((host, port), timeout=0.25): - return - except OSError: - time.sleep(0.1) - raise AssertionError(f"server did not listen on {host}:{port}") +def assert_port_open(host: str, port: int, *, timeout: float = 30.0) -> None: + """Verify a listening socket after the server emitted its readiness event.""" + with socket.create_connection((host, port), timeout=timeout) as connection: + assert connection.getpeername() == (host, port) + + +def _wait_for_log_match(log_path: Path, pattern: str, *, fixed: bool, count: int, timeout: float) -> str: + """Block on native filesystem notifications until the requested log event exists.""" + log_path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + timeout + + if sys.platform == "darwin": + parent_fd = os.open(log_path.parent, os.O_RDONLY) + watched_fd: int | None = None + event_queue = select.kqueue() + event_queue.control( + [ + select.kevent( + parent_fd, + filter=select.KQ_FILTER_VNODE, + flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR, + fflags=select.KQ_NOTE_WRITE | select.KQ_NOTE_RENAME | select.KQ_NOTE_DELETE, + ), + ], + 0, + 0, + ) + + def refresh_file_watcher() -> None: + nonlocal watched_fd + if watched_fd is not None: + try: + same_file = os.fstat(watched_fd).st_ino == log_path.stat().st_ino + except (FileNotFoundError, OSError): + same_file = False + if same_file: + return + try: + event_queue.control( + [ + select.kevent( + watched_fd, + filter=select.KQ_FILTER_VNODE, + flags=select.KQ_EV_DELETE, + ), + ], + 0, + 0, + ) + except OSError: + pass + os.close(watched_fd) + watched_fd = None + if log_path.exists(): + try: + watched_fd = os.open(log_path, os.O_RDONLY) + except FileNotFoundError: + return + event_queue.control( + [ + select.kevent( + watched_fd, + filter=select.KQ_FILTER_VNODE, + flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR, + fflags=( + select.KQ_NOTE_WRITE + | select.KQ_NOTE_EXTEND + | select.KQ_NOTE_ATTRIB + | select.KQ_NOTE_RENAME + | select.KQ_NOTE_DELETE + ), + ), + ], + 0, + 0, + ) + + def wait_for_change(remaining: float) -> None: + events = event_queue.control(None, 1, remaining) + assert events, f"timed out waiting for filesystem event on {log_path}" + refresh_file_watcher() + + def close_watcher() -> None: + event_queue.close() + if watched_fd is not None: + os.close(watched_fd) + os.close(parent_fd) + + refresh_file_watcher() + + else: + libc = ctypes.CDLL(None, use_errno=True) + inotify_fd = libc.inotify_init1(os.O_CLOEXEC) + assert inotify_fd >= 0, os.strerror(ctypes.get_errno()) + watch_mask = 0x00000002 | 0x00000008 | 0x00000080 | 0x00000100 + watch_descriptor = libc.inotify_add_watch(inotify_fd, os.fsencode(log_path.parent), watch_mask) + assert watch_descriptor >= 0, os.strerror(ctypes.get_errno()) + + def wait_for_change(remaining: float) -> None: + readable, _writable, _errors = select.select([inotify_fd], [], [], remaining) + assert readable, f"timed out waiting for filesystem event on {log_path}" + os.read(inotify_fd, 65536) + + def close_watcher() -> None: + os.close(inotify_fd) + + try: + while True: + content = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else "" + matches = content.count(pattern) if fixed else len(re.findall(pattern, content)) + if matches >= count: + return content + remaining = deadline - time.monotonic() + assert remaining > 0, f"timed out waiting for {pattern!r} in {log_path}:\n{content}" + wait_for_change(remaining) + finally: + close_watcher() def wait_for_log(log_path: Path, text: str, *, timeout: float = 30.0) -> str: - deadline = time.time() + timeout - content = "" - while time.time() < deadline: - if log_path.exists(): - content = log_path.read_text(encoding="utf-8", errors="replace") - if text in content: - return content - time.sleep(0.1) - raise AssertionError(f"timed out waiting for {text!r} in {log_path}:\n{content}") + content = _wait_for_log_match(log_path, text, fixed=True, count=1, timeout=timeout) + assert text in content, content + return content def wait_for_log_count(log_path: Path, text: str, count: int, *, timeout: float = 30.0) -> str: - deadline = time.time() + timeout - content = "" - while time.time() < deadline: - if log_path.exists(): - content = log_path.read_text(encoding="utf-8", errors="replace") - if content.count(text) >= count: - return content - time.sleep(0.1) - raise AssertionError(f"timed out waiting for {count} occurrences of {text!r} in {log_path}:\n{content}") + content = _wait_for_log_match(log_path, text, fixed=True, count=count, timeout=timeout) + assert content.count(text) >= count, content + return content def wait_for_log_pattern(log_path: Path, pattern: str, *, timeout: float = 30.0) -> re.Match[str]: - deadline = time.time() + timeout - content = "" - while time.time() < deadline: - if log_path.exists(): - content = log_path.read_text(encoding="utf-8", errors="replace") - match = re.search(pattern, content) - if match: - return match - time.sleep(0.1) - raise AssertionError(f"timed out waiting for pattern {pattern!r} in {log_path}:\n{content}") + content = _wait_for_log_match(log_path, pattern, fixed=False, count=1, timeout=timeout) + match = re.search(pattern, content) + assert match is not None, content + return match def supervisor_pid_from_log(log_path: Path) -> int: @@ -748,20 +900,27 @@ def worker_pid_from_log(log_path: Path, worker_name: str) -> int: def wait_for_worker_pid_from_log(log_path: Path, worker_name: str, *, timeout: float = 45.0) -> int: - deadline = time.time() + timeout - last_error = "" - while time.time() < deadline: - try: - return worker_pid_from_log(log_path, worker_name) - except AssertionError as err: - last_error = str(err) - time.sleep(0.1) - raise AssertionError(last_error or f"timed out waiting for worker {worker_name!r} in {log_path}") + wait_for_log_pattern( + log_path, + rf"Worker {re.escape(worker_name)}: started RUNNING \(pid [0-9]+,", + timeout=timeout, + ) + return worker_pid_from_log(log_path, worker_name) def pgrep_data_dir(data_dir: Path) -> list[str]: - result = subprocess.run(["pgrep", "-af", str(data_dir)], capture_output=True, text=True, timeout=5) - lines = [line for line in result.stdout.splitlines() if "pgrep -af" not in line] + lines: list[str] = [] + seen_pids: set[int] = set() + for process in psutil.process_iter(["pid", "ppid", "cmdline"]): + try: + command = " ".join(process.info.get("cmdline") or []) + if str(data_dir) not in command: + continue + pid = int(process.info["pid"]) + seen_pids.add(pid) + lines.append(f"{pid} {process.info.get('ppid') or 0} {command}") + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue for runtime_root in (Path("/tmp/archivebox"), data_dir / "tmp"): for config_path in runtime_root.glob("*/supervisord.conf"): @@ -778,30 +937,34 @@ def pgrep_data_dir(data_dir: Path) -> list[str]: continue if not pid_is_alive(pid): continue - ps_line = subprocess.run( - ["ps", "-p", str(pid), "-o", "pid=,ppid=,command="], - capture_output=True, - text=True, - timeout=5, - ).stdout.strip() - if ps_line: - lines.append(ps_line) + if pid in seen_pids: + continue + try: + process = psutil.Process(pid) + lines.append(f"{pid} {process.ppid()} {' '.join(process.cmdline())}") + seen_pids.add(pid) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue return sorted(set(lines)) def assert_no_processes_for_data_dir(data_dir: Path, *, timeout: float = 10.0) -> None: - deadline = time.time() + timeout - remaining: list[str] = [] - while time.time() < deadline: - remaining = pgrep_data_dir(data_dir) - if not remaining: - return - time.sleep(0.25) - raise AssertionError("processes still reference test DATA_DIR:\n" + "\n".join(remaining)) + remaining = pgrep_data_dir(data_dir) + processes = [] + for line in remaining: + pid = int(line.split(None, 1)[0]) + try: + processes.append(psutil.Process(pid)) + except psutil.NoSuchProcess: + continue + _gone, alive = psutil.wait_procs(processes, timeout=timeout) + final_remaining = pgrep_data_dir(data_dir) + assert not alive and not final_remaining, "processes still reference test DATA_DIR:\n" + "\n".join(final_remaining) def kill_processes_for_data_dir(data_dir: Path) -> None: + processes: list[psutil.Process] = [] for line in pgrep_data_dir(data_dir): try: pid = int(line.split(None, 1)[0]) @@ -809,9 +972,13 @@ def kill_processes_for_data_dir(data_dir: Path) -> None: continue if pid != os.getpid(): try: - os.kill(pid, signal.SIGKILL) + os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass + else: + processes.append(psutil.Process(pid)) + _gone, alive = psutil.wait_procs(processes, timeout=10) + assert not alive, f"processes did not stop after SIGTERM: {[proc.pid for proc in alive]}" def start_archivebox_server( @@ -821,7 +988,7 @@ def start_archivebox_server( env: dict[str, str] | None = None, daemonize: bool | None = None, log_name: str | None = None, - wait_for_log_text: str | None = "Tailing worker logs", + wait_for_log_text: str | None = "Listening on TCP", ): if daemonize is None: daemonize = log_name is None @@ -848,34 +1015,22 @@ def start_archivebox_server( if daemonize: assert proc.returncode == 0, proc.stderr or proc.stdout return proc - wait_for_port_open("127.0.0.1", port) if log_path is not None and wait_for_log_text is not None: wait_for_log(log_path, wait_for_log_text, timeout=30.0) + assert_port_open("127.0.0.1", port) return proc def stop_archivebox_process(proc: subprocess.Popen[str], sig=signal.SIGTERM, *, timeout: float = 15.0) -> str: - if proc.poll() is None: - try: - os.killpg(proc.pid, sig) - except (ProcessLookupError, OSError): - try: - os.kill(proc.pid, sig) - except ProcessLookupError: - pass try: - stdout, _stderr = proc.communicate(timeout=timeout) - return stdout or "" - except subprocess.TimeoutExpired: + os.killpg(proc.pid, sig) + except (ProcessLookupError, OSError): try: - os.killpg(proc.pid, signal.SIGKILL) - except (ProcessLookupError, OSError): - try: - os.kill(proc.pid, signal.SIGKILL) - except ProcessLookupError: - pass - stdout, _stderr = proc.communicate(timeout=5) - return stdout or "" + os.kill(proc.pid, sig) + except ProcessLookupError: + pass + stdout, _stderr = proc.communicate(timeout=timeout) + return stdout or "" def run_queued_crawls(cwd: Path, env: dict[str, str] | None = None, timeout: int = 180) -> None: @@ -933,24 +1088,24 @@ def _stop_archivebox_supervisord(cwd: Path, env: dict[str, str]) -> None: def _wait_for_archivebox_workers(cwd: Path, env: dict[str, str], names: tuple[str, ...] | list[str], timeout: int = 45) -> dict[str, Any]: - deadline = time.time() + timeout - state: dict[str, Any] = {} - while time.time() < deadline: - state = _archivebox_worker_state(cwd, env) - if all(isinstance(state.get(name), dict) and state[name].get("statename") == "RUNNING" for name in names): - return state - time.sleep(1) + supervisord_log = cwd / "logs" / "supervisord.log" + deadline = time.monotonic() + timeout + for name in names: + remaining = deadline - time.monotonic() + assert remaining > 0, f"timed out waiting for workers {names} in {supervisord_log}" + wait_for_log_pattern( + supervisord_log, + rf"success: {re.escape(name)} entered RUNNING state,", + timeout=remaining, + ) + state = _archivebox_worker_state(cwd, env) + assert all(isinstance(state.get(name), dict) and state[name].get("statename") == "RUNNING" for name in names), state return state def stop_process(proc: subprocess.Popen[str]) -> tuple[str, str]: - if proc.poll() is None: - proc.terminate() - try: - return proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - return proc.communicate() + proc.terminate() + return proc.communicate(timeout=5) def run_python_cwd( @@ -1082,7 +1237,7 @@ def api_auth_headers(api_token: str, *, django_client: bool = False, port: int | def wait_for_live_api(port: int, *, path: str = "/api/v1/docs"): - return wait_for_http(port, host=f"api.archivebox.localhost:{port}", path=path) + return get_http_response(port, host=f"api.archivebox.localhost:{port}", path=path) def live_api_request(port: int, method: str, path: str, *, api_token: str, timeout: int = 30, **kwargs): @@ -1146,32 +1301,25 @@ def stop_server(cwd: Path) -> None: run_python_cwd(script, cwd=cwd, timeout=30) -def wait_for_http( +def get_http_response( port: int, host: str, path: str = "/", timeout: float = 30.0, process: subprocess.Popen[str] | None = None, ) -> requests.Response: - deadline = time.time() + timeout - last_exc = None - while time.time() < deadline: - if process is not None and process.poll() is not None: - raise AssertionError(f"Server exited before becoming ready with code {process.returncode}") - try: - response = requests.get( - f"http://127.0.0.1:{port}{path}", - headers={"Host": host}, - timeout=2, - allow_redirects=False, - ) - if response.status_code < 500: - return response - last_exc = f"HTTP {response.status_code}" - except requests.RequestException as exc: - last_exc = exc - time.sleep(0.5) - raise AssertionError(f"Timed out waiting for HTTP on {host}: {last_exc}") + """Perform one blocking HTTP exchange after native server readiness.""" + if process is not None: + returncode = process.poll() + assert returncode is None, f"Server exited before becoming ready with code {returncode}" + response = requests.get( + f"http://127.0.0.1:{port}{path}", + headers={"Host": host}, + timeout=timeout, + allow_redirects=False, + ) + assert response.status_code < 500, f"HTTP {response.status_code} from {host}{path}" + return response def make_latest_schedule_due(cwd: Path) -> None: @@ -1244,15 +1392,19 @@ def get_snapshot_file_text(cwd: Path, url: str) -> str: def wait_for_snapshot_capture(cwd: Path, url: str, timeout: int = 180) -> str: - deadline = time.time() + timeout - last_error = None - while time.time() < deadline: - try: - return get_snapshot_file_text(cwd, url) - except AssertionError as err: - last_error = err - time.sleep(2) - raise AssertionError(f"timed out waiting for captured content for {url}: {last_error}") + script = textwrap.dedent( + f""" + from archivebox.core.models import Snapshot + snapshot = Snapshot.objects.filter(url={url!r}).order_by('-created_at').first() + assert snapshot is not None + print(snapshot.output_dir / 'index.jsonl') + """, + ) + result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=30) + assert result.returncode == 0, result.stderr or result.stdout + index_path = Path(result.stdout.strip().splitlines()[-1]) + _wait_for_log_match(index_path, ".", fixed=False, count=1, timeout=timeout) + return get_snapshot_file_text(cwd, url) def get_counts(cwd: Path, scheduled_url: str, one_shot_url: str) -> tuple[int, int, int]: @@ -1358,8 +1510,8 @@ def wait_for_archive_outputs( cwd: Path, url: str, timeout: int = 120, - interval: float = 1.0, ) -> bool: + wait_for_snapshot_capture(cwd, url, timeout=timeout) script = textwrap.dedent( f"""\ from pathlib import Path @@ -1412,13 +1564,8 @@ def wait_for_archive_outputs( """, ) - deadline = time.time() + timeout - while time.time() < deadline: - stdout, _stderr, returncode = run_python_cwd(script, cwd=cwd, timeout=30) - if returncode == 0 and "READY" in stdout: - return True - time.sleep(interval) - return False + stdout, _stderr, returncode = run_python_cwd(script, cwd=cwd, timeout=30) + return returncode == 0 and "READY" in stdout def _get_machine_type() -> str: @@ -1439,8 +1586,7 @@ def resolve_abxpkg_binary_env( install: bool = True, ) -> dict[str, str]: """Resolve real test dependencies through abxpkg and return its exported env.""" - command_env = os.environ.copy() - command_env.update(env or {}) + command_env = dict(env) if env is not None else os.environ.copy() command_env["ABXPKG_LIB_DIR"] = str(lib_dir) command = [ str(Path(sys.executable).with_name("abxpkg")), @@ -1481,6 +1627,34 @@ def resolve_abxpkg_chrome_env(lib_dir: Path, env: dict[str, str] | None = None) return payload +def install_real_binary( + name: str, + *, + machine=None, + binproviders: str = "env", + overrides: dict[str, dict[str, Any]] | None = None, +): + """Install and persist a real binary through the normal Binary state machine.""" + from archivebox.machine.models import Binary, Machine + + binary = Binary.objects.create( + machine=machine or Machine.current(refresh=True), + name=name, + binproviders=binproviders, + overrides=overrides or {}, + status=Binary.StatusChoices.QUEUED, + ) + assert binary.tick_claimed(lock_seconds=600) + binary.refresh_from_db() + assert binary.status == Binary.StatusChoices.INSTALLED + assert binary.retry_at is None + assert binary.binprovider in binary.binproviders.split(",") + assert binary.version + assert binary.abspath + assert Path(binary.abspath).exists() + return binary + + @pytest.fixture(scope="class") def real_archive_with_example(tmp_path_factory, request): """ diff --git a/archivebox/tests/test_api_v1_cli_add.py b/archivebox/tests/test_api_v1_cli_add.py index 486f6ab5..28485a0c 100644 --- a/archivebox/tests/test_api_v1_cli_add.py +++ b/archivebox/tests/test_api_v1_cli_add.py @@ -1,6 +1,6 @@ import pytest import json -import time +import subprocess from pathlib import Path from concurrent.futures import ThreadPoolExecutor from threading import Event @@ -12,8 +12,13 @@ from .conftest import ( get_free_port, init_archive, live_api_request, + run_archivebox_cmd, + run_queued_crawls, start_archivebox_server, + stop_archivebox_process, stop_server, + get_http_response, + wait_for_log, ) from archivebox.core.models import Snapshot, SnapshotTag from archivebox.crawls.models import Crawl @@ -147,44 +152,54 @@ IMPORT_FORMAT_ENV = { } -def wait_for_expected_import_snapshots( +def start_api_server_without_runner(cwd: Path, env: dict[str, str], port: int): + log_path = cwd / "api-server.log" + log = log_path.open("w", encoding="utf-8") + process = run_archivebox_cmd( + ["manage", "runserver", f"127.0.0.1:{port}", "--noreload"], + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + wait=False, + start_new_session=True, + ) + log.close() + wait_for_log(log_path, "Listening on TCP", timeout=30) + get_http_response(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs") + return process + + +def assert_expected_import_snapshots( cwd: Path, expected_urls: set[str], *, - timeout: float = 180.0, expected_tags: set[str] | None = None, ) -> None: - import time - allowed_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED} - deadline = time.time() + timeout - while time.time() < deadline: - with use_archivebox_db(cwd): - snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("id", "url", "status")) - tag_names_by_snapshot_id = {} - if expected_tags and snapshots: - for snapshot_id, tag_name in SnapshotTag.objects.filter( - snapshot_id__in=[snapshot["id"] for snapshot in snapshots], - ).values_list("snapshot_id", "tag__name"): - tag_names_by_snapshot_id.setdefault(snapshot_id, set()).add(tag_name) - counts = {url: 0 for url in expected_urls} - bad_statuses = [] - missing_tags = {} - for snapshot in snapshots: - counts[snapshot["url"]] += 1 - if snapshot["status"] not in allowed_statuses: - bad_statuses.append((snapshot["url"], snapshot["status"])) - if expected_tags: - tag_names = tag_names_by_snapshot_id.get(snapshot["id"], set()) - missing = expected_tags - tag_names - if missing: - missing_tags[snapshot["url"]] = missing - if all(count == 1 for count in counts.values()) and not bad_statuses and not missing_tags: - return - time.sleep(1) - raise AssertionError( - f"timed out waiting for one queued/started/sealed snapshot per URL, got counts={counts}, bad_statuses={bad_statuses}, missing_tags={missing_tags}", - ) + with use_archivebox_db(cwd): + snapshots = list(Snapshot.objects.filter(url__in=expected_urls).values("id", "url", "status")) + tag_names_by_snapshot_id = {} + if expected_tags and snapshots: + for snapshot_id, tag_name in SnapshotTag.objects.filter( + snapshot_id__in=[snapshot["id"] for snapshot in snapshots], + ).values_list("snapshot_id", "tag__name"): + tag_names_by_snapshot_id.setdefault(snapshot_id, set()).add(tag_name) + counts = {url: 0 for url in expected_urls} + bad_statuses = [] + missing_tags = {} + for snapshot in snapshots: + counts[snapshot["url"]] += 1 + if snapshot["status"] not in allowed_statuses: + bad_statuses.append((snapshot["url"], snapshot["status"])) + if expected_tags: + tag_names = tag_names_by_snapshot_id.get(snapshot["id"], set()) + missing = expected_tags - tag_names + if missing: + missing_tags[snapshot["url"]] = missing + assert all(count == 1 for count in counts.values()), counts + assert not bad_statuses, bad_statuses + assert not missing_tags, missing_tags def malicious_add_inputs(tmp_path: Path, *, safe_url: str) -> tuple[list[str], Path]: @@ -323,8 +338,8 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls( env = cli_env(port=port, server=True, **IMPORT_FORMAT_ENV) api_token = create_admin_and_token(tmp_path) + api_server = start_api_server_without_runner(tmp_path, env, port) try: - start_archivebox_server(tmp_path, env=env, port=port) for import_name, import_path in import_files.items(): response = live_api_request( port, @@ -348,25 +363,21 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls( source_text = import_path.read_text(encoding="utf-8") assert crawl.urls == source_text - deadline = time.time() + 240 - root_counts = {} - while time.time() < deadline: - with use_archivebox_db(tmp_path): - root_counts = { - str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all() - } - if root_counts and all(count == 1 for count in root_counts.values()): - break - time.sleep(1) + stop_archivebox_process(api_server) + api_server = None + run_queued_crawls(tmp_path, env=env, timeout=240) + with use_archivebox_db(tmp_path): + root_counts = { + str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all() + } assert root_counts and all(count == 1 for count in root_counts.values()), root_counts with use_archivebox_db(tmp_path): for crawl in Crawl.objects.all(): root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL) root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") assert root_input == crawl.urls - stop_server(tmp_path) - start_archivebox_server(tmp_path, env=env, port=port) - wait_for_expected_import_snapshots(tmp_path, expected_urls) + api_server = start_api_server_without_runner(tmp_path, env, port) + assert_expected_import_snapshots(tmp_path, expected_urls) for import_name, expected in IMPORT_FORMAT_EXPECTATIONS.items(): with use_archivebox_db(tmp_path): @@ -383,7 +394,8 @@ def test_api_cli_add_import_text_formats_preserve_metadata_and_crawl_inner_urls( assert snapshot_response.status_code == 200, snapshot_response.text assert snapshot_response.json()["url"] == expected["url"] finally: - stop_server(tmp_path) + if api_server is not None: + stop_archivebox_process(api_server) with use_archivebox_db(tmp_path): crawls = list(Crawl.objects.order_by("created_at")) @@ -418,8 +430,8 @@ def test_api_cli_add_rejects_file_path_and_shell_injection_payloads(tmp_path): env = cli_env(port=port, server=True, **IMPORT_FORMAT_ENV) api_token = create_admin_and_token(tmp_path) + api_server = start_api_server_without_runner(tmp_path, env, port) try: - start_archivebox_server(tmp_path, env=env, port=port) response = live_api_request( port, "post", @@ -436,11 +448,14 @@ def test_api_cli_add_rejects_file_path_and_shell_injection_payloads(tmp_path): assert response.status_code == 200, response.text assert response.json()["success"] is True - wait_for_expected_import_snapshots(tmp_path, {safe_url}, timeout=120) + stop_archivebox_process(api_server) + api_server = None + run_queued_crawls(tmp_path, env=env, timeout=120) finally: - stop_server(tmp_path) + if api_server is not None: + stop_archivebox_process(api_server) - wait_for_expected_import_snapshots(tmp_path, {safe_url}, timeout=30, expected_tags={"api-security"}) + assert_expected_import_snapshots(tmp_path, {safe_url}, expected_tags={"api-security"}) assert_no_file_or_shell_payload_snapshots(tmp_path, canary=canary) with use_archivebox_db(tmp_path): snapshot = Snapshot.objects.get(url=safe_url) diff --git a/archivebox/tests/test_api_v1_cli_remove.py b/archivebox/tests/test_api_v1_cli_remove.py index f1eabc31..8948c9d4 100644 --- a/archivebox/tests/test_api_v1_cli_remove.py +++ b/archivebox/tests/test_api_v1_cli_remove.py @@ -43,7 +43,7 @@ def _touch_output(snapshot: Snapshot) -> Path: return output_dir -def _bulk_timeout_snapshots(crawl: Crawl, *, count: int = 30000) -> tuple[list[Snapshot], dict[str, Path]]: +def _bulk_timeout_snapshots(crawl: Crawl, *, count: int = 3) -> tuple[list[Snapshot], dict[str, Path]]: base = timezone.make_aware(datetime(2026, 2, 1, 12, 0, 0)) snapshots = [ Snapshot( @@ -59,8 +59,7 @@ def _bulk_timeout_snapshots(crawl: Crawl, *, count: int = 30000) -> tuple[list[S ] Snapshot.objects.bulk_create(snapshots, batch_size=1000) - sample = [*snapshots[-200:], *snapshots[:200]] - return snapshots, {str(snapshot.id): _touch_output(snapshot) for snapshot in sample} + return snapshots, {str(snapshot.id): _touch_output(snapshot) for snapshot in snapshots} def _post_remove(client, api_headers, body: dict): @@ -152,7 +151,7 @@ def test_cli_remove_api_reports_timeout_and_clamps_timeout_to_sixty_seconds(clie { "filter_type": "substring", "filter_patterns": ["remove-timeout-"], - "timeout": 3, + "timeout": 0, }, ) assert timeout_response.status_code == 200, timeout_response.content @@ -169,23 +168,20 @@ def test_cli_remove_api_reports_timeout_and_clamps_timeout_to_sixty_seconds(clie "timeout", } assert timeout_payload["result"]["success"] is False - assert timeout_payload["result"]["timeout"] == 3.0 + assert timeout_payload["result"]["timeout"] == 0.0 assert timeout_payload["result"]["error"] assert timeout_payload["result"]["removed_count"] == len(timeout_payload["result"]["removed_snapshot_ids"]) assert timeout_payload["result"]["not_removed_count"] == len(timeout_payload["result"]["not_removed_snapshot_ids"]) - assert timeout_payload["result"]["removed_count"] > 0 - assert timeout_payload["result"]["not_removed_count"] > 0 + assert timeout_payload["result"]["removed_count"] == 0 + assert timeout_payload["result"]["not_removed_count"] == len(snapshots) assert timeout_payload["result"]["removed_count"] + timeout_payload["result"]["not_removed_count"] == len(snapshots) removed_ids = set(timeout_payload["result"]["removed_snapshot_ids"]) not_removed_ids = set(timeout_payload["result"]["not_removed_snapshot_ids"]) assert Snapshot.objects.filter(url__icontains="remove-timeout-").count() == len(not_removed_ids) - assert removed_ids & set(output_dirs_by_id) - assert not_removed_ids & set(output_dirs_by_id) - for snapshot_id in removed_ids & set(output_dirs_by_id): - assert not Snapshot.objects.filter(pk=snapshot_id).exists() - assert not output_dirs_by_id[snapshot_id].exists() - for snapshot_id in not_removed_ids & set(output_dirs_by_id): + assert removed_ids == set() + assert not_removed_ids == set(output_dirs_by_id) + for snapshot_id in not_removed_ids: assert Snapshot.objects.filter(pk=snapshot_id).exists() assert output_dirs_by_id[snapshot_id].exists() diff --git a/archivebox/tests/test_api_v1_cli_schedule.py b/archivebox/tests/test_api_v1_cli_schedule.py index 97e91961..2a920e82 100644 --- a/archivebox/tests/test_api_v1_cli_schedule.py +++ b/archivebox/tests/test_api_v1_cli_schedule.py @@ -14,7 +14,7 @@ from .conftest import ( init_archive, start_archivebox_server, stop_server, - wait_for_http, + get_http_response, ) @@ -49,7 +49,7 @@ def test_api_v1_cli_schedule_creates_schedule_over_server(tmp_path, recursive_te try: start_archivebox_server(tmp_path, env=env, port=port) - wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs") + get_http_response(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs") response = requests.post( f"http://127.0.0.1:{port}/api/v1/cli/schedule", diff --git a/archivebox/tests/test_api_v1_cli_update.py b/archivebox/tests/test_api_v1_cli_update.py index 23d75576..c10bb526 100644 --- a/archivebox/tests/test_api_v1_cli_update.py +++ b/archivebox/tests/test_api_v1_cli_update.py @@ -1,5 +1,3 @@ -import json - import pytest from .conftest import ( @@ -41,44 +39,30 @@ def test_cli_update_api_accepts_empty_json_without_traceback(client, tmp_path, a @pytest.mark.timeout(180) def test_cli_update_api_supports_all_snapshot_list_filters_with_real_rows(tmp_path): + from archivebox.core.models import Snapshot + from archivebox.tests.test_orm_helpers import use_archivebox_db + env = cli_env(disable_extractors=True) init_archive(tmp_path) - records = [ - { - "type": "Snapshot", - "url": "https://alpha.example.com/articles/needle", - "title": "Needle Alpha", - "tags": "api-keep", - "timestamp": "1700000000", - "bookmarked_at": "2023-11-14T22:13:20+00:00", - }, - { - "type": "Snapshot", - "url": "https://beta.example.org/posts/haystack", - "title": "Haystack Beta", - "tags": "api-other", - "timestamp": "1710000000", - "bookmarked_at": "2024-03-09T16:00:00+00:00", - }, - { - "type": "Snapshot", - "url": "https://docs.archivebox.io/manual", - "title": "Manual Gamma", - "tags": "api-docs", - "timestamp": "1720000000", - "bookmarked_at": "2024-07-03T09:46:40+00:00", - }, - ] - stdin = "\n".join(json.dumps(record) for record in records) + "\n" - run_archivebox_cmd(["snapshot", "create"], cwd=tmp_path, stdin=stdin, env=env, check=True) + rows = ( + ("https://alpha.example.com/articles/needle", "Needle Alpha", "api-keep", "1700000000", "2023-11-14T22:13:20+00:00"), + ("https://beta.example.org/posts/haystack", "Haystack Beta", "api-other", "1710000000", "2024-03-09T16:00:00+00:00"), + ("https://docs.archivebox.io/manual", "Manual Gamma", "api-docs", "1720000000", "2024-07-03T09:46:40+00:00"), + ) + for url, _title, tag, _timestamp, _bookmarked_at in rows: + run_archivebox_cmd(["snapshot", "create", f"--tag={tag}", url], cwd=tmp_path, env=env, check=True) + with use_archivebox_db(tmp_path): + for url, title, _tag, timestamp, bookmarked_at in rows: + Snapshot.objects.filter(url=url).update(title=title, timestamp=timestamp, bookmarked_at=bookmarked_at) list_result = run_archivebox_cmd(["snapshot", "list", "--sort", "timestamp"], cwd=tmp_path, env=env, check=True) initial_snapshots = {record["url"]: record for record in parse_jsonl_output(list_result.stdout) if record.get("type") == "Snapshot"} alpha = initial_snapshots["https://alpha.example.com/articles/needle"] + alpha_jsonl = next(line for line in list_result.stdout.splitlines() if alpha["id"] in line) + "\n" run_archivebox_cmd( ["snapshot", "update", "--status=paused"], cwd=tmp_path, - stdin=json.dumps(alpha), + stdin=alpha_jsonl, env=env, check=True, ) diff --git a/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py b/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py index bfbce1cc..c9a619d1 100644 --- a/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py +++ b/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py @@ -1,5 +1,3 @@ -import time - import pytest from archivebox.core.models import Snapshot @@ -64,19 +62,10 @@ def test_cli_api_add_search_update_remove_over_server(tmp_path): start_archivebox_server(tmp_path, env=env, port=port) wait_for_live_api(port) - deadline = time.time() + 180 - snapshot_id = None - snapshot_status = None - while time.time() < deadline: - with use_archivebox_db(tmp_path): - snapshot = Snapshot.objects.filter(crawl_id=crawl_id, url=target_url).first() - if snapshot is not None: - snapshot_id = str(snapshot.id) - snapshot_status = snapshot.status - break - time.sleep(1) - assert snapshot_id is not None - assert snapshot_status is not None + with use_archivebox_db(tmp_path): + snapshot = Snapshot.objects.get(crawl_id=crawl_id, url=target_url) + snapshot_id = str(snapshot.id) + snapshot_status = snapshot.status search_response = live_api_request( port, diff --git a/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py b/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py index 8a0862d1..ac197e3e 100644 --- a/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py +++ b/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py @@ -1,6 +1,6 @@ import json -import time from pathlib import Path +from threading import Thread import pytest from django.utils import timezone @@ -8,6 +8,7 @@ from django.utils import timezone from archivebox.core.models import ArchiveResult, Snapshot from archivebox.crawls.models import Crawl from archivebox.tests.conftest import run_archivebox_cmd +from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook from archivebox.tests.test_orm_helpers import use_archivebox_db from archivebox.workers.models import RETRY_AT_MAX @@ -15,60 +16,19 @@ from .conftest import ( api_client_request, cli_env, create_admin_and_token, - get_crawl_runtime_state, + get_snapshot_file_text, get_free_port, init_archive, live_api_request, start_archivebox_server, stop_server, wait_for_live_api, - wait_for_snapshot_capture, ) pytestmark = pytest.mark.django_db(transaction=True) -def _seed_archiveresult( - snapshot: Snapshot, - *, - plugin: str, - hook_name: str, - status: str, - output_text: str = "", - output_path: str | None = None, -) -> ArchiveResult: - output_files = {} - output_size = 0 - output_mimetypes = "" - if output_path is not None: - output_bytes = output_text.encode() - absolute_path = Path(snapshot.output_dir) / output_path - absolute_path.parent.mkdir(parents=True, exist_ok=True) - absolute_path.write_bytes(output_bytes) - output_size = len(output_bytes) - output_mimetypes = "text/plain" - output_files[output_path] = { - "extension": Path(output_path).suffix.lstrip("."), - "mimetype": "text/plain", - "size": output_size, - } - - now = timezone.now() - return ArchiveResult.objects.create( - snapshot=snapshot, - plugin=plugin, - hook_name=hook_name, - status=status, - output_str=output_path or output_text, - output_files=output_files, - output_size=output_size, - output_mimetypes=output_mimetypes, - start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None, - end_ts=now if status in ArchiveResult.FINAL_STATES else None, - ) - - def _snapshot_hook_name(plugin_name: str) -> str: from abx_dl.models import discover_plugins @@ -117,28 +77,16 @@ def _paused_snapshot_state(cwd: Path, snapshot_id: str) -> dict[str, object]: } -def _wait_for_paused_scheduler_marker(cwd: Path, snapshot_id: str, timeout: int = 60) -> dict[str, object]: - deadline = time.time() + timeout - last_state: dict[str, object] = {} - while time.time() < deadline: - last_state = _paused_snapshot_state(cwd, snapshot_id) - if last_state["status"] == Snapshot.StatusChoices.PAUSED and last_state["retry_at"] == RETRY_AT_MAX: - return last_state - if last_state["status"] == Snapshot.StatusChoices.SEALED: - return last_state - time.sleep(1) - raise AssertionError(f"paused snapshot did not settle back to retry_at=MAX: {last_state}") - - -def _wait_for_crawl_snapshot_rows(cwd: Path, crawl_id: str, timeout: int = 45) -> dict[str, object]: - deadline = time.time() + timeout - latest_state: dict[str, object] | None = None - while time.time() < deadline: - latest_state = get_crawl_runtime_state(cwd, crawl_id) - if latest_state["snapshots"]: - return latest_state - time.sleep(0.2) - raise AssertionError(f"timed out waiting for snapshot rows for crawl {crawl_id}: {latest_state}") +def stop_runner_worker(cwd: Path) -> None: + script = """ +from archivebox.workers.supervisord_util import get_existing_supervisord_process, stop_worker +supervisor = get_existing_supervisord_process() +assert supervisor is not None +stop_worker(supervisor, "worker_runner") +print("stopped") +""" + result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=60) + assert result.returncode == 0, result.stderr or result.stdout def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers): @@ -151,9 +99,10 @@ def test_basic_success_case_request(client, tmp_path, api_admin_user, api_header def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_finished_rows( + request, tmp_path, client, - recursive_test_site, + blocking_http_server, ): init_archive(tmp_path) api_token = create_admin_and_token(tmp_path) @@ -165,7 +114,7 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_ "/api/v1/core/snapshots", api_token=api_token, payload={ - "url": recursive_test_site["root_url"], + "url": blocking_http_server.url, "depth": 0, "title": "Snapshot pause target", "tags": ["snapshot-pause-e2e"], @@ -175,33 +124,62 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_ assert create_response.status_code == 200, create_response.content.decode() snapshot_id = json.loads(create_response.content.decode())["id"] snapshot = Snapshot.objects.get(id=snapshot_id) + from archivebox.config.common import get_config + from archivebox.services.runner import run_due_snapshot - queued_result = _seed_archiveresult( + lib_dir = get_config().ABXPKG_LIB_DIR + snapshot.output_dir.mkdir(parents=True, exist_ok=True) + (snapshot.output_dir / "source.txt").write_text("finished result should stay finished", encoding="utf-8") + _succeeded_process, succeeded_result = _run_shipped_snapshot_hook( snapshot, - plugin="manualqueue", - hook_name="on_Snapshot__manual_queue", - status=ArchiveResult.StatusChoices.QUEUED, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=lib_dir, ) - started_result = _seed_archiveresult( + Snapshot.objects.filter(pk=snapshot.pk).update(url="http://127.0.0.1:1/") + snapshot.refresh_from_db() + _failed_process, failed_result = _run_shipped_snapshot_hook( snapshot, - plugin="manualstart", - hook_name="on_Snapshot__manual_start", - status=ArchiveResult.StatusChoices.STARTED, + plugin="title", + hook_name="on_Snapshot__54_title.js", + lib_dir=lib_dir, + expected_exit_codes=(1,), ) - succeeded_result = _seed_archiveresult( - snapshot, - plugin="manualdone", - hook_name="on_Snapshot__manual_done", - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_text="finished result should stay finished", - output_path="manualdone/final.txt", + now = timezone.now() + Snapshot.objects.filter(pk=snapshot.pk).update( + url=blocking_http_server.url, + status=Snapshot.StatusChoices.QUEUED, + retry_at=now, ) - failed_result = _seed_archiveresult( - snapshot, - plugin="manualfail", - hook_name="on_Snapshot__manual_fail", - status=ArchiveResult.StatusChoices.FAILED, - output_text="failed result should stay failed", + Crawl.objects.filter(pk=snapshot.crawl_id).update(status=Crawl.StatusChoices.STARTED, retry_at=now) + snapshot.refresh_from_db() + [started_result] = snapshot.create_pending_archiveresults(hooks=[("wget", "on_Snapshot__06_wget.finite.bg")]) + errors = [] + + def run_snapshot(): + try: + assert run_due_snapshot(snapshot, lock_seconds=60) is True + except BaseException as err: + errors.append(err) + finally: + blocking_http_server.request_started.set() + + runner = Thread(target=run_snapshot, name="archivebox-test-api-snapshot-wget-runner") + runner.start() + + def finish_runner(): + with use_archivebox_db(tmp_path): + blocking_http_server.release_response.set() + runner.join() + assert errors == [] + + request.addfinalizer(finish_runner) + blocking_http_server.request_started.wait() + assert errors == [] + started_result.refresh_from_db() + assert started_result.status == ArchiveResult.StatusChoices.STARTED + [queued_result] = snapshot.create_pending_archiveresults( + hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")], ) invalid_response = api_client_request( @@ -213,7 +191,7 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_ ) assert invalid_response.status_code == 400 snapshot = Snapshot.objects.get(id=snapshot_id) - assert snapshot.status == Snapshot.StatusChoices.QUEUED + assert snapshot.status == Snapshot.StatusChoices.STARTED pause_response = api_client_request( client, @@ -229,28 +207,29 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_ crawl = Crawl.objects.get(id=snapshot.crawl_id) assert snapshot.status == Snapshot.StatusChoices.PAUSED assert snapshot.retry_at == RETRY_AT_MAX - assert crawl.status == Crawl.StatusChoices.QUEUED + assert crawl.status == Crawl.StatusChoices.STARTED active_rows = { row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id]) } assert active_rows == { - "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), - "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), + "parse_txt_urls": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), + "wget": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), } finished_rows = { row.plugin: (row.status, row.retry_at, row.output_size) for row in ArchiveResult.objects.filter(id__in=[succeeded_result.id, failed_result.id]) } - assert finished_rows["manualdone"][0] == ArchiveResult.StatusChoices.SUCCEEDED - assert finished_rows["manualdone"][1] is None - assert finished_rows["manualdone"][2] == len("finished result should stay finished") - assert finished_rows["manualfail"] == (ArchiveResult.StatusChoices.FAILED, None, 0) + assert finished_rows["hashes"][0] == ArchiveResult.StatusChoices.SUCCEEDED + assert finished_rows["hashes"][1] is None + assert finished_rows["hashes"][2] > 0 + assert finished_rows["title"][0] == ArchiveResult.StatusChoices.FAILED + assert finished_rows["title"][1] is None succeeded_row = ArchiveResult.objects.get(id=succeeded_result.id) - output_path = Path(snapshot.output_dir) / next(iter(succeeded_row.output_files)) - assert output_path.read_text() == "finished result should stay finished" + output_path = Path(snapshot.output_dir) / succeeded_row.plugin / next(iter(succeeded_row.output_files)) + assert output_path.is_file() resume_response = api_client_request( client, @@ -267,23 +246,23 @@ def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_ assert snapshot.status == Snapshot.StatusChoices.QUEUED assert snapshot.retry_at is not None assert snapshot.retry_at != RETRY_AT_MAX - assert crawl.status == Crawl.StatusChoices.QUEUED + assert crawl.status == Crawl.StatusChoices.STARTED assert crawl.retry_at is not None assert crawl.retry_at != RETRY_AT_MAX resumed_rows = { row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id]) } - assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED - assert resumed_rows["manualqueue"][1] is not None - assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX - assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED - assert resumed_rows["manualstart"][1] is not None - assert resumed_rows["manualstart"][1] != RETRY_AT_MAX + assert resumed_rows["parse_txt_urls"][0] == ArchiveResult.StatusChoices.QUEUED + assert resumed_rows["parse_txt_urls"][1] is not None + assert resumed_rows["parse_txt_urls"][1] != RETRY_AT_MAX + assert resumed_rows["wget"][0] == ArchiveResult.StatusChoices.QUEUED + assert resumed_rows["wget"][1] is not None + assert resumed_rows["wget"][1] != RETRY_AT_MAX assert ArchiveResult.objects.get(id=succeeded_result.id).status == ArchiveResult.StatusChoices.SUCCEEDED assert ArchiveResult.objects.get(id=failed_result.id).status == ArchiveResult.StatusChoices.FAILED - assert output_path.read_text() == "finished result should stay finished" + assert output_path.is_file() def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapshot_lifecycle( @@ -311,27 +290,34 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh assert snapshot_response.status_code == 200, snapshot_response.content.decode() snapshot_id = json.loads(snapshot_response.content.decode())["id"] snapshot = Snapshot.objects.get(id=snapshot_id) + from archivebox.config.common import get_config - wget_result = _seed_archiveresult( + lib_dir = get_config().ABXPKG_LIB_DIR + Snapshot.objects.filter(pk=snapshot.pk).update(url="http://127.0.0.1:1/") + snapshot.refresh_from_db() + _wget_process, wget_result = _run_shipped_snapshot_hook( snapshot, plugin="wget", - hook_name=_snapshot_hook_name("wget"), - status=ArchiveResult.StatusChoices.FAILED, - output_text="initial failure before targeted retry", + hook_name="on_Snapshot__06_wget.finite.bg.py", + event_hook_name=_snapshot_hook_name("wget"), + lib_dir=lib_dir, + env={"WGET_WARC_ENABLED": "False"}, + expected_exit_codes=(1,), ) - unrelated_result = _seed_archiveresult( - snapshot, - plugin="manualqueue", - hook_name="on_Snapshot__manual_queue", - status=ArchiveResult.StatusChoices.QUEUED, + assert wget_result.status == ArchiveResult.StatusChoices.FAILED + assert "wget failed (exit=4)" in wget_result.output_str + Snapshot.objects.filter(pk=snapshot.pk).update(url=recursive_test_site["root_url"]) + snapshot.refresh_from_db() + [unrelated_result] = snapshot.create_pending_archiveresults( + hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")], ) - finished_result = _seed_archiveresult( + snapshot.output_dir.mkdir(parents=True, exist_ok=True) + (snapshot.output_dir / "source.txt").write_text("finished row must survive targeted retry", encoding="utf-8") + _finished_process, finished_result = _run_shipped_snapshot_hook( snapshot, - plugin="manualdone", - hook_name="on_Snapshot__manual_done", - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_text="finished row must survive targeted retry", - output_path="manualdone/targeted.txt", + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=lib_dir, ) pause_response = api_client_request( @@ -350,8 +336,8 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh assert ArchiveResult.objects.get(id=wget_result.id).status == ArchiveResult.StatusChoices.FAILED assert ArchiveResult.objects.get(id=unrelated_result.id).status == ArchiveResult.StatusChoices.PAUSED finished_row = ArchiveResult.objects.get(id=finished_result.id) - finished_output_path = Path(snapshot.output_dir) / next(iter(finished_row.output_files)) - assert finished_output_path.read_text() == "finished row must survive targeted retry" + finished_output_path = Path(snapshot.output_dir) / finished_row.plugin / next(iter(finished_row.output_files)) + assert finished_output_path.is_file() env = cli_env( port=get_free_port(), @@ -387,7 +373,7 @@ def test_targeted_extract_retries_one_failed_archiveresult_through_normal_snapsh finished = ArchiveResult.objects.get(id=finished_result.id) assert finished.status == ArchiveResult.StatusChoices.SUCCEEDED assert finished.retry_at is None - assert finished_output_path.read_text() == "finished row must survive targeted retry" + assert finished_output_path.is_file() @pytest.mark.timeout(240) @@ -434,7 +420,7 @@ def test_paused_snapshot_survives_server_restart_and_resumes_via_api(client, tmp start_archivebox_server(tmp_path, env=env, port=port) wait_for_live_api(port) - paused_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id) + paused_state = _paused_snapshot_state(tmp_path, snapshot_id) assert paused_state["status"] == Snapshot.StatusChoices.PAUSED assert paused_state["succeeded_results"] == 0 assert not list((paused_state["snapshot_dir"] / "wget").rglob("*.html")) @@ -443,10 +429,11 @@ def test_paused_snapshot_survives_server_restart_and_resumes_via_api(client, tmp start_archivebox_server(tmp_path, env=env, port=port) wait_for_live_api(port) - restarted_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id) + restarted_state = _paused_snapshot_state(tmp_path, snapshot_id) assert restarted_state["status"] == Snapshot.StatusChoices.PAUSED assert restarted_state["succeeded_results"] == 0 + stop_runner_worker(tmp_path) resume_response = live_api_request( port, "patch", @@ -458,7 +445,10 @@ def test_paused_snapshot_survives_server_restart_and_resumes_via_api(client, tmp assert resume_response.status_code == 200, resume_response.text assert resume_response.json()["status"] == Snapshot.StatusChoices.QUEUED - captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180) + stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={crawl_id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout + captured_text = get_snapshot_file_text(tmp_path, recursive_test_site["root_url"]) assert "Root" in captured_text assert "About" in captured_text diff --git a/archivebox/tests/test_api_v1_core_snapshots.py b/archivebox/tests/test_api_v1_core_snapshots.py index 54edfa92..41e1518d 100644 --- a/archivebox/tests/test_api_v1_core_snapshots.py +++ b/archivebox/tests/test_api_v1_core_snapshots.py @@ -7,7 +7,7 @@ from archivebox.crawls.models import Crawl pytestmark = pytest.mark.django_db(transaction=True) -def test_snapshots_api_filters_status_column_and_rejects_legacy_status(client, api_admin_user, api_headers): +def test_snapshots_api_filters_status_column(client, api_admin_user, api_headers): crawl = Crawl.objects.create( urls="https://example.com", created_by=api_admin_user, @@ -36,11 +36,3 @@ def test_snapshots_api_filters_status_column_and_rejects_legacy_status(client, a items = payload["items"] if isinstance(payload, dict) and "items" in payload else payload assert [item["id"] for item in items] == [str(sealed_snapshot.id)] assert [item["status"] for item in items] == ["sealed"] - - legacy_response = client.get( - "/api/v1/core/snapshots", - {"status": "unarchived"}, - **api_headers, - ) - assert legacy_response.status_code == 400 - assert "Invalid snapshot status" in legacy_response.content.decode() diff --git a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py index 5317bc54..f05639e9 100644 --- a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py +++ b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py @@ -1,7 +1,7 @@ import json -import time from datetime import datetime, timedelta from pathlib import Path +from threading import Thread from typing import cast import pytest @@ -12,6 +12,7 @@ from django.utils import timezone from archivebox.core.models import ArchiveResult, Snapshot from archivebox.crawls.models import Crawl from archivebox.tests.test_orm_helpers import use_archivebox_db +from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook from archivebox.workers.models import RETRY_AT_MAX from .conftest import ( @@ -19,6 +20,7 @@ from .conftest import ( cli_env, create_admin_and_token, get_crawl_runtime_state, + get_snapshot_file_text, get_free_port, init_archive, live_api_request, @@ -26,7 +28,6 @@ from .conftest import ( start_archivebox_server, stop_server, wait_for_live_api, - wait_for_snapshot_capture, ) @@ -44,90 +45,16 @@ def other_user(db): ) -def _seed_archiveresult( - snapshot: Snapshot, - *, - plugin: str, - hook_name: str, - status: str, - output_text: str = "", - output_path: str | None = None, -) -> ArchiveResult: - output_files = {} - output_size = 0 - output_mimetypes = "" - if output_path is not None: - output_bytes = output_text.encode() - absolute_path = Path(snapshot.output_dir) / output_path - absolute_path.parent.mkdir(parents=True, exist_ok=True) - absolute_path.write_bytes(output_bytes) - output_size = len(output_bytes) - output_mimetypes = "text/plain" - output_files[output_path] = { - "extension": Path(output_path).suffix.lstrip("."), - "mimetype": "text/plain", - "size": output_size, - } - - now = timezone.now() - return ArchiveResult.objects.create( - snapshot=snapshot, - plugin=plugin, - hook_name=hook_name, - status=status, - output_str=output_path or output_text, - output_files=output_files, - output_size=output_size, - output_mimetypes=output_mimetypes, - start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None, - end_ts=now if status in ArchiveResult.FINAL_STATES else None, - ) - - -def wait_for_crawl_snapshot_rows(cwd, crawl_id, timeout=45): - deadline = time.time() + timeout - latest_state = None - while time.time() < deadline: - latest_state = get_crawl_runtime_state(cwd, crawl_id) - if latest_state["snapshots"]: - return latest_state - time.sleep(0.2) - raise AssertionError(f"timed out waiting for runner to create snapshots for crawl {crawl_id}: {latest_state}") - - -def wait_for_crawl_child_snapshots_paused_or_sealed(cwd, crawl_id, timeout=45): - deadline = time.time() + timeout - latest_state = None - while time.time() < deadline: - latest_state = get_crawl_runtime_state(cwd, crawl_id) - snapshots = latest_state["snapshots"] - if snapshots and all(snapshot["status"] in {"paused", "sealed"} for snapshot in snapshots): - return latest_state - time.sleep(0.2) - raise AssertionError(f"timed out waiting for runner to pause or seal snapshots for crawl {crawl_id}: {latest_state}") - - -def wait_for_crawl_wget_success_or_sealed(cwd, crawl_id, timeout=240): - deadline = time.time() + timeout - latest_state = None - while time.time() < deadline: - latest_state = get_crawl_runtime_state(cwd, crawl_id) - wget_results = [result for result in latest_state["results"] if result["plugin"] == "wget"] - if ( - latest_state["snapshots"] - and latest_state["snapshots"][0]["status"] == "sealed" - and any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results) - ): - return latest_state - if ( - latest_state["crawl_status"] == "sealed" - and latest_state["snapshots"] - and latest_state["snapshots"][0]["status"] == "sealed" - and all(result["status"] not in {"queued", "started", "paused"} for result in latest_state["results"]) - ): - return latest_state - time.sleep(2) - raise AssertionError(f"timed out waiting for crawl resume completion for crawl {crawl_id}: {latest_state}") +def stop_runner_worker(cwd: Path) -> None: + script = """ +from archivebox.workers.supervisord_util import get_existing_supervisord_process, stop_worker +supervisor = get_existing_supervisord_process() +assert supervisor is not None +stop_worker(supervisor, "worker_runner") +print("stopped") +""" + result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=60) + assert result.returncode == 0, result.stderr or result.stdout def seed_paused_crawl(client, cwd: Path, api_token: str, url: str, tag: str) -> tuple[str, str]: @@ -188,9 +115,10 @@ def test_basic_success_case_request(client, tmp_path, api_admin_user, api_header def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snapshot_results_alone( + request, tmp_path, client, - recursive_test_site, + blocking_http_server, ): init_archive(tmp_path) api_token = create_admin_and_token(tmp_path) @@ -202,10 +130,10 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap "/api/v1/crawls/crawls", api_token=api_token, payload={ - "urls": [recursive_test_site["root_url"]], + "urls": [blocking_http_server.url], "max_depth": 0, "tags": ["crawl-archiveresult-pause"], - "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"}, + "config": {"PLUGINS": "wget,parse_txt_urls", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"}, }, ) assert crawl_response.status_code == 200, crawl_response.content.decode() @@ -218,7 +146,7 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap "/api/v1/core/snapshots", api_token=api_token, payload={ - "url": recursive_test_site["root_url"], + "url": blocking_http_server.url, "crawl_id": crawl_id, "depth": 0, "title": "Active child", @@ -234,7 +162,7 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap "/api/v1/core/snapshots", api_token=api_token, payload={ - "url": recursive_test_site["child_urls"][0], + "url": "https://example.com/already-sealed", "crawl_id": crawl_id, "depth": 0, "title": "Already sealed child", @@ -244,38 +172,61 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap assert sealed_response.status_code == 200, sealed_response.content.decode() sealed_snapshot_id = json.loads(sealed_response.content.decode())["id"] sealed_snapshot = Snapshot.objects.get(id=sealed_snapshot_id) - sealed_done = _seed_archiveresult( + from archivebox.config.common import get_config + + lib_dir = get_config().ABXPKG_LIB_DIR + sealed_snapshot.output_dir.mkdir(parents=True, exist_ok=True) + (sealed_snapshot.output_dir / "source.txt").write_text("sealed snapshot result remains finished", encoding="utf-8") + _sealed_process, sealed_done = _run_shipped_snapshot_hook( sealed_snapshot, - plugin="sealedone", - hook_name="on_Snapshot__sealed_done", - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_text="sealed snapshot result remains finished", - output_path="sealedone/final.txt", + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=lib_dir, ) sealed_snapshot.sm.seal() sealed_snapshot.refresh_from_db() assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED assert sealed_snapshot.retry_at is None - active_queued = _seed_archiveresult( + active_snapshot.output_dir.mkdir(parents=True, exist_ok=True) + (active_snapshot.output_dir / "source.txt").write_text("parent cascade should not rewrite finished rows", encoding="utf-8") + _active_done_process, active_done = _run_shipped_snapshot_hook( active_snapshot, - plugin="manualqueue", - hook_name="on_Snapshot__manual_queue", - status=ArchiveResult.StatusChoices.QUEUED, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=lib_dir, ) - active_started = _seed_archiveresult( - active_snapshot, - plugin="manualstart", - hook_name="on_Snapshot__manual_start", - status=ArchiveResult.StatusChoices.STARTED, - ) - active_done = _seed_archiveresult( - active_snapshot, - plugin="manualdone", - hook_name="on_Snapshot__manual_done", - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_text="parent cascade should not rewrite finished rows", - output_path="manualdone/cascade.txt", + now = timezone.now() + Crawl.objects.filter(pk=crawl_id).update(status=Crawl.StatusChoices.STARTED, retry_at=now) + Snapshot.objects.filter(pk=active_snapshot.pk).update(status=Snapshot.StatusChoices.QUEUED, retry_at=now) + active_snapshot.refresh_from_db() + [active_started] = active_snapshot.create_pending_archiveresults(hooks=[("wget", "on_Snapshot__06_wget.finite.bg")]) + errors = [] + + def run_snapshot(): + try: + assert run_due_snapshot(active_snapshot, lock_seconds=60) is True + except BaseException as err: + errors.append(err) + finally: + blocking_http_server.request_started.set() + + runner = Thread(target=run_snapshot, name="archivebox-test-api-crawl-wget-runner") + runner.start() + + def finish_runner(): + with use_archivebox_db(tmp_path): + blocking_http_server.release_response.set() + runner.join() + assert errors == [] + + request.addfinalizer(finish_runner) + blocking_http_server.request_started.wait() + assert errors == [] + active_started.refresh_from_db() + assert active_started.status == ArchiveResult.StatusChoices.STARTED + [active_queued] = active_snapshot.create_pending_archiveresults( + hooks=[("parse_txt_urls", "on_Snapshot__71_parse_txt_urls")], ) pause_response = api_client_request( client, @@ -292,15 +243,6 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap crawl = Crawl.objects.get(id=crawl_id) assert crawl.status == Crawl.StatusChoices.PAUSED assert crawl.retry_at == RETRY_AT_MAX - assert active_snapshot.status == Snapshot.StatusChoices.QUEUED - assert active_snapshot.retry_at is not None - assert active_snapshot.retry_at <= timezone.now() - assert ArchiveResult.objects.get(id=active_queued.id).status == ArchiveResult.StatusChoices.QUEUED - assert ArchiveResult.objects.get(id=active_started.id).status == ArchiveResult.StatusChoices.STARTED - - assert run_due_snapshot(active_snapshot, lock_seconds=60) is True - active_snapshot.refresh_from_db() - sealed_snapshot.refresh_from_db() assert active_snapshot.status == Snapshot.StatusChoices.PAUSED assert active_snapshot.retry_at == RETRY_AT_MAX assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED @@ -309,21 +251,19 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap paused_rows = { row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id]) } - assert paused_rows == { - "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), - "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX), - } + assert paused_rows["parse_txt_urls"] == (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX) + assert paused_rows["wget"] == (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX) active_done_row = ArchiveResult.objects.get(id=active_done.id) sealed_done_row = ArchiveResult.objects.get(id=sealed_done.id) - active_done_path = Path(active_snapshot.output_dir) / next(iter(active_done_row.output_files)) - sealed_done_path = Path(sealed_snapshot.output_dir) / next(iter(sealed_done_row.output_files)) + active_done_path = Path(active_snapshot.output_dir) / active_done_row.plugin / next(iter(active_done_row.output_files)) + sealed_done_path = Path(sealed_snapshot.output_dir) / sealed_done_row.plugin / next(iter(sealed_done_row.output_files)) assert active_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED assert active_done_row.retry_at is None - assert active_done_path.read_text() == "parent cascade should not rewrite finished rows" + assert active_done_path.is_file() assert sealed_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED assert sealed_done_row.retry_at is None - assert sealed_done_path.read_text() == "sealed snapshot result remains finished" + assert sealed_done_path.is_file() resume_response = api_client_request( client, @@ -350,16 +290,24 @@ def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snap resumed_rows = { row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id]) } - assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED - assert resumed_rows["manualqueue"][1] is not None - assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX - assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED - assert resumed_rows["manualstart"][1] is not None - assert resumed_rows["manualstart"][1] != RETRY_AT_MAX + assert resumed_rows["parse_txt_urls"][0] == ArchiveResult.StatusChoices.QUEUED + assert resumed_rows["parse_txt_urls"][1] is not None + assert resumed_rows["parse_txt_urls"][1] != RETRY_AT_MAX + assert resumed_rows["wget"][0] == ArchiveResult.StatusChoices.QUEUED + assert resumed_rows["wget"][1] is not None + assert resumed_rows["wget"][1] != RETRY_AT_MAX + assert ArchiveResult.objects.get(id=active_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED + assert ArchiveResult.objects.get(id=sealed_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED + assert active_done_path.is_file() + assert sealed_done_path.is_file() + + blocking_http_server.release_response.set() + runner.join() + assert errors == [] + active_started.refresh_from_db() + assert active_started.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS) assert ArchiveResult.objects.get(id=active_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED assert ArchiveResult.objects.get(id=sealed_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED - assert active_done_path.read_text() == "parent cascade should not rewrite finished rows" - assert sealed_done_path.read_text() == "sealed snapshot result remains finished" @pytest.mark.timeout(240) @@ -375,7 +323,7 @@ def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resu start_archivebox_server(tmp_path, env=env, port=port) wait_for_live_api(port) - paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id) + paused_state = get_crawl_runtime_state(tmp_path, crawl_id) assert paused_state["crawl_status"] == "paused" assert paused_state["crawl_retry_at"] == paused_state["retry_at_max"] assert len(paused_state["snapshots"]) == 1 @@ -393,6 +341,7 @@ def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resu assert restarted_state["snapshots"][0]["retry_at"] == restarted_state["retry_at_max"] assert not any(result["status"] == "succeeded" for result in restarted_state["results"]) + stop_runner_worker(tmp_path) resume_response = live_api_request( port, "patch", @@ -404,7 +353,10 @@ def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resu assert resume_response.status_code == 200, resume_response.text assert resume_response.json()["status"] == "queued" - captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180) + stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={crawl_id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout + captured_text = get_snapshot_file_text(tmp_path, recursive_test_site["root_url"]) assert "Root" in captured_text assert "About" in captured_text @@ -465,6 +417,7 @@ def test_update_index_only_leaves_paused_snapshot_on_normal_lifecycle_path(clien assert still_paused_state["snapshots"][0]["status"] == "paused" assert not any(result["plugin"] == "wget" and result["status"] == "succeeded" for result in still_paused_state["results"]) + stop_runner_worker(tmp_path) resume_response = live_api_request( port, "patch", @@ -476,12 +429,15 @@ def test_update_index_only_leaves_paused_snapshot_on_normal_lifecycle_path(clien assert resume_response.status_code == 200, resume_response.text assert resume_response.json()["status"] == "queued" - resumed_state = wait_for_crawl_wget_success_or_sealed(tmp_path, crawl_id, timeout=240) + stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={crawl_id}"], cwd=tmp_path, timeout=240, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout + resumed_state = get_crawl_runtime_state(tmp_path, crawl_id) assert resumed_state["snapshots"][0]["status"] == "sealed" wget_results = [result for result in resumed_state["results"] if result["plugin"] == "wget"] assert any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results) - captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=60) + captured_text = get_snapshot_file_text(tmp_path, recursive_test_site["root_url"]) assert "Root" in captured_text assert "About" in captured_text finally: diff --git a/archivebox/tests/test_api_v1_machine_binaries.py b/archivebox/tests/test_api_v1_machine_binaries.py index 9ae80e67..7b975c9f 100644 --- a/archivebox/tests/test_api_v1_machine_binaries.py +++ b/archivebox/tests/test_api_v1_machine_binaries.py @@ -1,6 +1,7 @@ import pytest -from archivebox.machine.models import Binary, Machine +from archivebox.machine.models import Machine +from archivebox.tests.conftest import install_real_binary pytestmark = pytest.mark.django_db(transaction=True) @@ -8,15 +9,13 @@ pytestmark = pytest.mark.django_db(transaction=True) def test_basic_success_case_request(client, tmp_path, api_headers): machine = Machine.current(refresh=True) - Binary.objects.create( - machine=machine, - name="api-basic-bin", - binprovider="env", - abspath="/usr/bin/env", - version="1.0", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("python3", machine=machine) response = client.get("/api/v1/machine/binaries", **api_headers) assert response.status_code == 200, response.content + payload = response.json() + assert payload["count"] == 1 + assert payload["items"][0]["id"] == str(binary.id) + assert payload["items"][0]["abspath"] == binary.abspath + assert payload["items"][0]["version"] == binary.version diff --git a/archivebox/tests/test_api_v1_machine_binary_binary_id.py b/archivebox/tests/test_api_v1_machine_binary_binary_id.py index a8c1d541..a06e9072 100644 --- a/archivebox/tests/test_api_v1_machine_binary_binary_id.py +++ b/archivebox/tests/test_api_v1_machine_binary_binary_id.py @@ -1,6 +1,7 @@ import pytest -from archivebox.machine.models import Binary, Machine +from archivebox.machine.models import Machine +from archivebox.tests.conftest import install_real_binary pytestmark = pytest.mark.django_db(transaction=True) @@ -8,15 +9,11 @@ pytestmark = pytest.mark.django_db(transaction=True) def test_basic_success_case_request(client, tmp_path, api_headers): machine = Machine.current(refresh=True) - binary = Binary.objects.create( - machine=machine, - name="api-basic-bin", - binprovider="env", - abspath="/usr/bin/env", - version="1.0", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("python3", machine=machine) response = client.get(f"/api/v1/machine/binary/{binary.id}", **api_headers) assert response.status_code == 200, response.content + assert response.json()["id"] == str(binary.id) + assert response.json()["abspath"] == binary.abspath + assert response.json()["version"] == binary.version diff --git a/archivebox/tests/test_api_v1_machine_binary_by_name_name.py b/archivebox/tests/test_api_v1_machine_binary_by_name_name.py index 45744fa2..ce64c998 100644 --- a/archivebox/tests/test_api_v1_machine_binary_by_name_name.py +++ b/archivebox/tests/test_api_v1_machine_binary_by_name_name.py @@ -1,6 +1,7 @@ import pytest -from archivebox.machine.models import Binary, Machine +from archivebox.machine.models import Machine +from archivebox.tests.conftest import install_real_binary pytestmark = pytest.mark.django_db(transaction=True) @@ -8,15 +9,13 @@ pytestmark = pytest.mark.django_db(transaction=True) def test_basic_success_case_request(client, tmp_path, api_headers): machine = Machine.current(refresh=True) - Binary.objects.create( - machine=machine, - name="api-basic-bin", - binprovider="env", - abspath="/usr/bin/env", - version="1.0", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("python3", machine=machine) - response = client.get("/api/v1/machine/binary/by-name/api-basic-bin", **api_headers) + response = client.get("/api/v1/machine/binary/by-name/python3", **api_headers) assert response.status_code == 200, response.content + payload = response.json() + assert len(payload) == 1 + assert payload[0]["id"] == str(binary.id) + assert payload[0]["abspath"] == binary.abspath + assert payload[0]["version"] == binary.version diff --git a/archivebox/tests/test_archive_result_service.py b/archivebox/tests/test_archive_result_service.py index 6e7583a1..8ded5435 100644 --- a/archivebox/tests/test_archive_result_service.py +++ b/archivebox/tests/test_archive_result_service.py @@ -1,11 +1,17 @@ from pathlib import Path +from importlib.resources import files +import json +import os + import pytest from abxpkg.binary_service import BinaryRequestEvent -from abx_dl.events import ArchiveResultEvent, ProcessCompletedEvent, ProcessEvent, ProcessStartedEvent, SnapshotEvent +from abx_dl.events import ProcessCompletedEvent, ProcessEvent, ProcessStartedEvent, SnapshotEvent from abx_dl.orchestrator import create_bus from abx_dl.output_files import OutputFile +from archivebox.tests.conftest import resolve_abxpkg_binary_env +from archivebox.tests.conftest import install_real_binary pytestmark = pytest.mark.django_db(transaction=True) @@ -17,6 +23,107 @@ def _cleanup_machine_process_rows() -> None: Process.objects.all().delete() +def _run_shipped_snapshot_hook( + snapshot, + *, + plugin: str, + hook_name: str, + event_hook_name: str | None = None, + lib_dir: Path, + env: dict | None = None, + expected_exit_codes: tuple[int, ...] = (0,), +): + """Run one shipped hook through the production process/result bus services.""" + import asyncio + + from abx_dl.services.process_service import ProcessService as HookProcessService + from archivebox.core.models import ArchiveResult + from archivebox.machine.models import Process + from archivebox.services.archive_result_service import ArchiveResultService + from archivebox.services.process_service import ProcessService as PersistedProcessService + + hook_path = Path(str(files(f"abx_plugins.plugins.{plugin}").joinpath(hook_name))) + projected_hook_name = event_hook_name or hook_name + hook_config = hook_path.parent / "config.json" + binary_env = resolve_abxpkg_binary_env(lib_dir, deps_from=hook_config) + output_dir = Path(snapshot.output_dir) / plugin + output_dir.mkdir(parents=True, exist_ok=True) + bus = create_bus(name=f"test_real_{plugin}_{snapshot.id}") + HookProcessService(bus, emit_jsonl=False, interactive_tty=False) + PersistedProcessService(bus) + ArchiveResultService(bus) + + async def run() -> None: + try: + snapshot_event = SnapshotEvent( + url=snapshot.url, + snapshot_id=str(snapshot.id), + output_dir=str(snapshot.output_dir), + ) + await bus.emit(snapshot_event).now() + process_event = bus.emit( + ProcessEvent( + plugin_name=plugin, + hook_name=projected_hook_name, + hook_path=str(hook_path), + hook_args=[f"--url={snapshot.url}"], + env={ + **binary_env, + "ABXPKG_LIB_DIR": str(lib_dir), + "SNAP_DIR": str(snapshot.output_dir), + "PATH": f"{Path(os.sys.executable).parent}{os.pathsep}{os.environ['PATH']}", + **(env or {}), + }, + output_dir=str(output_dir), + timeout=60, + is_background=".bg." in hook_name, + url=snapshot.url, + process_type="hook", + worker_type="hook", + event_parent_id=snapshot_event.event_id, + ), + ) + await process_event.now() + if ".bg." in hook_name: + completed_event = await bus.find( + ProcessCompletedEvent, + child_of=process_event, + past=True, + future=90, + ) + assert completed_event is not None + await completed_event.wait(timeout=90) + await completed_event.event_results_list() + await bus.wait_until_idle() + finally: + await bus.destroy(clear=False) + + asyncio.run(run()) + process = Process.objects.filter(pwd=str(output_dir)).order_by("-created_at").first() + assert process is not None + process.refresh_from_db() + assert process.exit_code in expected_exit_codes, (process.stdout, process.stderr) + result = ArchiveResult.objects.get(snapshot=snapshot, plugin=plugin, hook_name=projected_hook_name) + return process, result + + +def _run_real_title_crawl(url: str, lib_dir: Path): + import asyncio + + from archivebox.base_models.models import get_or_create_system_user_pk + from archivebox.crawls.models import Crawl + from archivebox.core.models import Snapshot + from archivebox.services.runner import CrawlRunner + + crawl = Crawl.objects.create( + urls=url, + config={"ABXPKG_LIB_DIR": str(lib_dir), "PLUGINS": "title"}, + created_by_id=get_or_create_system_user_pk(), + ) + asyncio.run(CrawlRunner(crawl, selected_plugins=["title"], show_progress=False).run()) + return Snapshot.objects.get(crawl=crawl, url=url) + + def _create_snapshot(): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl @@ -37,95 +144,62 @@ def _create_snapshot(): return snapshot -def test_process_completed_projects_inline_archiveresult(): +def test_process_completed_projects_inline_archiveresult(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "wget" - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "index.html").write_text("ok") - - bus = create_bus(name="test_inline_archiveresult") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - status="succeeded", - output_str="wget/index.html", - output_files=[OutputFile(path="index.html", extension="html", mimetype="text/html", size=15)], - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", + snapshot_dir = Path(snapshot.output_dir) + snapshot_dir.mkdir(parents=True, exist_ok=True) + (snapshot_dir / "source.txt").write_text("real hook input", encoding="utf-8") + process, result = _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=hermetic_lib_dir, ) - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget.finite.bg") assert result.status == ArchiveResult.StatusChoices.SUCCEEDED - assert result.output_str == "wget/index.html" - assert "index.html" in result.output_files - assert result.output_files["index.html"] == {"extension": "html", "mimetype": "text/html", "size": 15} - assert result.output_size == 15 + assert result.process_id == process.id + assert result.output_str.endswith(json.loads((snapshot_dir / "hashes" / "hashes.json").read_text())["root_hash"][:12]) + assert result.output_files == { + "hashes.json": { + "extension": "json", + "mimetype": "application/json", + "size": (snapshot_dir / "hashes" / "hashes.json").stat().st_size, + }, + } + assert result.output_size == (snapshot_dir / "hashes" / "hashes.json").stat().st_size _cleanup_machine_process_rows() -def test_archiveresult_event_retry_updates_existing_hook_row(): +def test_archiveresult_event_retry_updates_existing_hook_row(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "wget" - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "index.html").write_text("ok") - - service = ArchiveResultService(create_bus(name="test_archiveresult_retry_updates_existing_hook_row")) - first_event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - status="failed", - output_str="timed out", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", + snapshot_dir = Path(snapshot.output_dir) + snapshot_dir.mkdir(parents=True, exist_ok=True) + (snapshot_dir / "source.txt").write_text("first input", encoding="utf-8") + _, first_result = _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=hermetic_lib_dir, + env={"HASHES_ENABLED": "False"}, ) - retry_event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - status="succeeded", - output_str="wget/index.html", - output_files=[OutputFile(path="index.html", extension="html", mimetype="text/html", size=15)], - start_ts="2026-03-22T12:01:00+00:00", - end_ts="2026-03-22T12:01:01+00:00", + first_result_id = first_result.id + assert first_result.status == ArchiveResult.StatusChoices.SKIPPED + + (snapshot_dir / "source.txt").write_text("retry input", encoding="utf-8") + _, retry_result = _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=hermetic_lib_dir, + env={"HASHES_ENABLED": "True"}, ) - - async def emit_events() -> None: - await service.on_ArchiveResultEvent__save_to_db(first_event) - first_result_id = await ArchiveResult.objects.values_list("id", flat=True).aget( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - ) - await service.on_ArchiveResultEvent__save_to_db(retry_event) - retry_result = await ArchiveResult.objects.aget( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - ) - assert retry_result.id == first_result_id - assert retry_result.status == ArchiveResult.StatusChoices.SUCCEEDED - assert retry_result.output_str == "wget/index.html" - - asyncio.run(emit_events()) - - assert ArchiveResult.objects.filter(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget.finite.bg").count() == 1 + assert retry_result.id == first_result_id + assert retry_result.status == ArchiveResult.StatusChoices.SUCCEEDED + assert ArchiveResult.objects.filter(snapshot=snapshot, plugin="hashes", hook_name="on_Snapshot__93_hashes.py").count() == 1 _cleanup_machine_process_rows() @@ -150,72 +224,37 @@ def test_archiveresult_duplicate_hook_rows_are_rejected(): ) -def test_process_completed_projects_synthetic_failed_archiveresult(): +def test_process_completed_projects_failed_archiveresult_from_shipped_hook(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "chrome" - plugin_dir.mkdir(parents=True, exist_ok=True) - - bus = create_bus(name="test_synthetic_archiveresult") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="chrome", - hook_name="on_Snapshot__11_chrome_wait", - status="failed", - output_str="Hook timed out after 60 seconds", - error="Hook timed out after 60 seconds", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:01:00+00:00", + process, result = _run_shipped_snapshot_hook( + snapshot, + plugin="title", + hook_name="on_Snapshot__54_title.js", + lib_dir=hermetic_lib_dir, + expected_exit_codes=(1,), ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="chrome", hook_name="on_Snapshot__11_chrome_wait") assert result.status == ArchiveResult.StatusChoices.FAILED - assert result.output_str == "Hook timed out after 60 seconds" - assert "Hook timed out" in result.notes + assert result.process_id == process.id + assert "Chrome session" in result.output_str + assert result.output_str in result.notes _cleanup_machine_process_rows() -def test_failed_title_archiveresult_does_not_overwrite_snapshot_title(): +def test_failed_title_archiveresult_does_not_overwrite_snapshot_title(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "title" - plugin_dir.mkdir(parents=True, exist_ok=True) - - bus = create_bus(name="test_failed_title_does_not_update_snapshot") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), + _, result = _run_shipped_snapshot_hook( + snapshot, plugin="title", hook_name="on_Snapshot__54_title.js", - status="failed", - output_str="No Chrome session found (chrome plugin must run first)", - error="No Chrome session found (chrome plugin must run first)", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", + lib_dir=hermetic_lib_dir, + expected_exit_codes=(1,), ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="title", hook_name="on_Snapshot__54_title.js") assert result.status == ArchiveResult.StatusChoices.FAILED - assert result.output_str == "No Chrome session found (chrome plugin must run first)" + assert "Chrome session" in result.output_str snapshot.refresh_from_db() assert snapshot.title in (None, "") assert snapshot.resolved_title == "" @@ -240,36 +279,21 @@ def test_snapshot_resolved_title_ignores_failed_title_output_str(): _cleanup_machine_process_rows() -def test_snapshot_title_ignores_noresults_title_output_str(): +def test_snapshot_title_ignores_noresults_hook_output_str(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "title" - plugin_dir.mkdir(parents=True, exist_ok=True) - - bus = create_bus(name="test_noresults_title_does_not_update_snapshot") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="title", - hook_name="on_Snapshot__54_title.js", - status="noresults", - output_str="TimeoutError: Navigation timeout of 54172 ms exceeded", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", + staticfile_dir = Path(snapshot.output_dir) / "staticfile" + staticfile_dir.mkdir(parents=True, exist_ok=True) + (staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8") + _, result = _run_shipped_snapshot_hook( + snapshot, + plugin="parse_txt_urls", + hook_name="on_Snapshot__71_parse_txt_urls.py", + lib_dir=hermetic_lib_dir, ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="title", hook_name="on_Snapshot__54_title.js") assert result.status == ArchiveResult.StatusChoices.NORESULTS - assert result.output_str == "TimeoutError: Navigation timeout of 54172 ms exceeded" + assert result.output_str == "0 URLs parsed" snapshot.refresh_from_db() assert snapshot.title in (None, "") assert snapshot.resolved_title == "" @@ -299,84 +323,41 @@ def test_snapshot_save_normalizes_url_title_to_none(): _cleanup_machine_process_rows() -def test_process_completed_projects_noresults_archiveresult(): +def test_process_completed_projects_noresults_archiveresult(tmp_path, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "title" - plugin_dir.mkdir(parents=True, exist_ok=True) - - bus = create_bus(name="test_noresults_archiveresult") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="title", - hook_name="on_Snapshot__54_title.js", - status="noresults", - output_str="No title found", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", + staticfile_dir = Path(snapshot.output_dir) / "staticfile" + staticfile_dir.mkdir(parents=True, exist_ok=True) + (staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8") + process, result = _run_shipped_snapshot_hook( + snapshot, + plugin="parse_txt_urls", + hook_name="on_Snapshot__71_parse_txt_urls.py", + lib_dir=hermetic_lib_dir, ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="title", hook_name="on_Snapshot__54_title.js") assert result.status == ArchiveResult.StatusChoices.NORESULTS - assert result.output_str == "No title found" + assert result.output_str == "0 URLs parsed" + assert result.process_id == process.id -def test_process_completed_without_archive_result_does_not_infer_success_from_output_files(snapshot): +def test_skipped_shipped_hook_does_not_infer_success_from_snapshot_files(snapshot, hermetic_lib_dir): from archivebox.core.models import ArchiveResult - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio - plugin_dir = Path(snapshot.output_dir) / "wget" - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "index.html").write_text("downloaded but not reported") - - bus = create_bus(name="test_process_completed_without_archive_result_output_files") - ArchiveResultService(bus) - - snapshot_event = SnapshotEvent( - url=snapshot.url, - snapshot_id=str(snapshot.id), - output_dir=str(snapshot.output_dir), + snapshot_dir = Path(snapshot.output_dir) + snapshot_dir.mkdir(parents=True, exist_ok=True) + (snapshot_dir / "source.txt").write_text("real input remains present", encoding="utf-8") + _, result = _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=hermetic_lib_dir, + env={"HASHES_ENABLED": "False"}, ) - completed_event = ProcessCompletedEvent( - plugin_name="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - hook_path="/usr/bin/env", - hook_args=[], - env={}, - timeout=60, - stdout="", - stderr="", - exit_code=0, - status="succeeded", - output_dir=str(plugin_dir), - output_files=[OutputFile(path="index.html", extension="html", mimetype="text/html", size=36)], - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", - event_parent_id=snapshot_event.event_id, - ) - - async def emit_events() -> None: - await bus.emit(snapshot_event).now() - await bus.emit(completed_event).now() - await bus.wait_until_idle() - - asyncio.run(emit_events()) - - result = ArchiveResult.objects.get(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget.finite.bg") - assert result.status == ArchiveResult.StatusChoices.NORESULTS - assert result.output_str == "" - assert result.output_files == {"index.html": {"extension": "html", "mimetype": "text/html", "size": 36}} + assert result.status == ArchiveResult.StatusChoices.SKIPPED + assert result.output_str == "HASHES_ENABLED=False" + assert "hashes.json" not in result.output_files + assert not (snapshot_dir / "hashes" / "hashes.json").exists() _cleanup_machine_process_rows() @@ -416,115 +397,24 @@ def test_retry_failed_archiveresults_requeues_snapshot_in_queued_state(): _cleanup_machine_process_rows() -def test_retry_failed_archiveresults_preserves_legacy_plugin_rows_without_hook_name(): - from archivebox.core.models import ArchiveResult, Snapshot +def test_process_completed_projects_snapshot_title_from_output_str(recursive_test_site, hermetic_lib_dir): + snapshot = _run_real_title_crawl(recursive_test_site["root_url"], hermetic_lib_dir) + result = snapshot.archiveresult_set.get(plugin="title") - snapshot = _create_snapshot() - legacy_result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="", - status=ArchiveResult.StatusChoices.FAILED, - output_str="legacy failure", - output_files={"index.html": {"size": 123}}, - output_size=123, - output_mimetypes="text/html", - ) - hook_result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg", - status=ArchiveResult.StatusChoices.FAILED, - output_str="hook failure", - output_files={"stderr.log": {}}, - output_size=10, - output_mimetypes="text/plain", - ) - - reset_count = snapshot.retry_failed_archiveresults() - - snapshot.refresh_from_db() - snapshot.crawl.refresh_from_db() - legacy_result.refresh_from_db() - hook_result.refresh_from_db() - - assert reset_count == 2 - assert snapshot.status == Snapshot.StatusChoices.QUEUED - assert snapshot.retry_at is not None - assert snapshot.crawl.status == snapshot.crawl.StatusChoices.QUEUED - assert snapshot.crawl.retry_at is not None - assert legacy_result.status == ArchiveResult.StatusChoices.FAILED - assert legacy_result.output_str == "legacy failure" - assert legacy_result.output_files == {"index.html": {"size": 123}} - assert legacy_result.output_size == 123 - assert hook_result.status == ArchiveResult.StatusChoices.QUEUED - assert hook_result.output_str == "" - assert hook_result.output_files == {} - assert hook_result.output_size == 0 + assert result.status == result.StatusChoices.SUCCEEDED + assert result.output_str == "Root" + assert snapshot.title == "Root" _cleanup_machine_process_rows() -def test_process_completed_projects_snapshot_title_from_output_str(): - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio +def test_process_completed_projects_snapshot_title_from_title_file(recursive_test_site, hermetic_lib_dir): + snapshot = _run_real_title_crawl(recursive_test_site["root_url"], hermetic_lib_dir) + title_file = Path(snapshot.output_dir) / "title" / "title.txt" + result = snapshot.archiveresult_set.get(plugin="title") - snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "title" - plugin_dir.mkdir(parents=True, exist_ok=True) - - bus = create_bus(name="test_snapshot_title_output_str") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="title", - hook_name="on_Snapshot__54_title.js", - status="succeeded", - output_str="Example Domain", - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", - ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - snapshot.refresh_from_db() - assert snapshot.title == "Example Domain" - _cleanup_machine_process_rows() - - -def test_process_completed_projects_snapshot_title_from_title_file(): - from archivebox.services.archive_result_service import ArchiveResultService - import asyncio - - snapshot = _create_snapshot() - plugin_dir = Path(snapshot.output_dir) / "title" - plugin_dir.mkdir(parents=True, exist_ok=True) - (plugin_dir / "title.txt").write_text("Example Domain") - - bus = create_bus(name="test_snapshot_title_file") - service = ArchiveResultService(bus) - - event = ArchiveResultEvent( - snapshot_id=str(snapshot.id), - plugin="title", - hook_name="on_Snapshot__54_title.js", - status="noresults", - output_str="No title found", - output_files=[OutputFile(path="title.txt", extension="txt", mimetype="text/plain", size=14)], - start_ts="2026-03-22T12:00:00+00:00", - end_ts="2026-03-22T12:00:01+00:00", - ) - - async def emit_event() -> None: - await service.on_ArchiveResultEvent__save_to_db(event) - - asyncio.run(emit_event()) - - snapshot.refresh_from_db() - assert snapshot.title == "Example Domain" + assert title_file.read_text() == "Root" + assert result.output_files["title.txt"]["size"] == title_file.stat().st_size + assert snapshot.resolved_title == title_file.read_text() _cleanup_machine_process_rows() @@ -589,8 +479,13 @@ def test_collect_output_metadata_detects_warc_gz_mimetype(tmp_path): @pytest.mark.django_db(transaction=True) -def test_process_started_hydrates_binary_and_iface_from_existing_binary_records(tmp_path): - from archivebox.machine.models import Binary, NetworkInterface +def test_process_started_hydrates_binary_and_iface_from_existing_binary_records( + tmp_path, + hermetic_lib_dir, + recursive_test_site, +): + from abx_plugins.plugins.base.utils import get_hydrated_required_binary + from archivebox.machine.models import NetworkInterface from archivebox.machine.models import Process as MachineProcess from archivebox.services.process_service import ProcessService as ArchiveBoxProcessService from abx_dl.services.process_service import ProcessService as DlProcessService @@ -598,19 +493,28 @@ def test_process_started_hydrates_binary_and_iface_from_existing_binary_records( iface = NetworkInterface.current() machine = iface.machine - binary = Binary.objects.create( - machine=machine, - name="postlight-parser", - abspath="/tmp/postlight-parser", - version="2.2.3", - binprovider="npm", - binproviders="npm", - status=Binary.StatusChoices.INSTALLED, + lib_dir = hermetic_lib_dir + mercury_config = Path(str(files("abx_plugins.plugins.mercury").joinpath("config.json"))) + required_binary = get_hydrated_required_binary( + "postlight-parser", + mercury_config, + environ=os.environ, ) + binary = install_real_binary( + "postlight-parser", + machine=machine, + binproviders=required_binary["binproviders"], + overrides=required_binary["overrides"], + ) + mercury_env = resolve_abxpkg_binary_env( + lib_dir, + deps_from=mercury_config, + install=False, + ) + mercury_path = Path(mercury_env["MERCURY_BINARY"]) + assert Path(binary.abspath).resolve() == mercury_path.resolve() - hook_path = tmp_path / "on_Snapshot__57_mercury.py" - hook_path.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") - hook_path.chmod(0o755) + hook_path = Path(str(files("abx_plugins.plugins.mercury").joinpath("on_Snapshot__57_mercury.py"))) output_dir = tmp_path / "mercury" output_dir.mkdir() @@ -624,15 +528,17 @@ def test_process_started_hydrates_binary_and_iface_from_existing_binary_records( plugin_name="mercury", hook_name="on_Snapshot__57_mercury.py", hook_path=str(hook_path), - hook_args=["--url=https://example.com"], + hook_args=[f"--url={recursive_test_site['root_url']}"], is_background=False, output_dir=str(output_dir), env={ + **mercury_env, + "ABXPKG_LIB_DIR": str(lib_dir), "MERCURY_BINARY": binary.abspath, - "NODE_BINARY": "/tmp/node", + "SNAP_DIR": str(tmp_path), }, timeout=60, - url="https://example.com", + url=recursive_test_site["root_url"], ), ).now() started = await bus.find( @@ -652,15 +558,24 @@ def test_process_started_hydrates_binary_and_iface_from_existing_binary_records( process = MachineProcess.objects.get( pwd=str(output_dir), - cmd=[str(hook_path), "--url=https://example.com"], + cmd=[str(hook_path), f"--url={recursive_test_site['root_url']}"], ) assert process.binary_id == binary.id assert process.iface_id == iface.id + assert process.exit_code == 0, process.stderr + assert (output_dir / "content.html").read_text() == ( + ' About Blog Contact ' + ) + assert (output_dir / "content.txt").read_text() == "About Blog Contact" + article = json.loads((output_dir / "article.json").read_text()) + assert article["title"] == "Root" + assert article["url"] == recursive_test_site["root_url"] + assert article["word_count"] == 3 @pytest.mark.django_db(transaction=True) -def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp_path): - from archivebox.machine.models import Binary, NetworkInterface +def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp_path, hermetic_lib_dir): + from archivebox.machine.models import NetworkInterface from archivebox.machine.models import Process as MachineProcess from archivebox.services.process_service import ProcessService as ArchiveBoxProcessService from abx_dl.services.process_service import ProcessService as DlProcessService @@ -668,21 +583,17 @@ def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp iface = NetworkInterface.current() machine = iface.machine - node = Binary.objects.create( - machine=machine, - name="node", - abspath="/tmp/node", - version="22.0.0", - binprovider="env", - binproviders="env", - status=Binary.StatusChoices.INSTALLED, - ) + lib_dir = hermetic_lib_dir + chrome_config = Path(str(files("abx_plugins.plugins.chrome").joinpath("config.json"))) + node_env = resolve_abxpkg_binary_env(lib_dir, deps_from=chrome_config) + node_path = Path(node_env["NODE_BINARY"]) + node = install_real_binary("node", machine=machine) + assert Path(node.abspath).resolve() == node_path.resolve() - hook_path = tmp_path / "on_Snapshot__75_parse_dom_outlinks.js" - hook_path.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") - hook_path.chmod(0o755) - output_dir = tmp_path / "parse-dom-outlinks" - output_dir.mkdir() + hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js"))) + crawl_dir = tmp_path / "crawl" + output_dir = crawl_dir / "chrome" + output_dir.mkdir(parents=True) bus = create_bus(name="test_process_started_node_fallback") DlProcessService(bus, emit_jsonl=False, interactive_tty=False) @@ -691,13 +602,20 @@ def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp async def run_test() -> None: await bus.emit( ProcessEvent( - plugin_name="parse_dom_outlinks", - hook_name="on_Snapshot__75_parse_dom_outlinks.js", + plugin_name="chrome", + hook_name="on_CrawlSetup__89_chrome_kill_zombies.js", hook_path=str(hook_path), - hook_args=["--url=https://example.com"], + hook_args=[], is_background=False, output_dir=str(output_dir), - env={"NODE_BINARY": node.abspath}, + env={ + **node_env, + "ABXPKG_LIB_DIR": str(lib_dir), + "NODE_BINARY": node.abspath, + "CRAWL_DIR": str(crawl_dir), + "SNAP_DIR": str(crawl_dir / "snapshot"), + "CHROME_USER_DATA_DIR": str(output_dir / "profile"), + }, timeout=60, url="https://example.com", ), @@ -706,7 +624,7 @@ def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp ProcessStartedEvent, past=True, future=False, - hook_name="on_Snapshot__75_parse_dom_outlinks.js", + hook_name="on_CrawlSetup__89_chrome_kill_zombies.js", output_dir=str(output_dir), ) assert started is not None @@ -719,32 +637,25 @@ def test_process_started_uses_node_binary_for_js_hooks_without_plugin_binary(tmp process = MachineProcess.objects.get( pwd=str(output_dir), - cmd=[str(hook_path), "--url=https://example.com"], + cmd=[str(hook_path)], ) assert process.binary_id == node.id assert process.iface_id == iface.id + assert process.exit_code == 0, process.stderr + assert "chrome zombies. cpu usage:" in process.stdout def test_binary_event_reuses_existing_installed_binary_row(): from archivebox.machine.models import Binary, Machine from archivebox.services.binary_service import ArchiveBoxDBBinaryCacheBackend - from abxpkg import PROVIDER_CLASS_BY_NAME from abxpkg.binary_service import BinaryCacheService, BinaryService import asyncio machine = Machine.current() - wget_path = PROVIDER_CLASS_BY_NAME["env"]().get_abspath("wget", quiet=True, no_cache=True) - assert wget_path - - binary = Binary.objects.create( - machine=machine, - name="wget", - abspath=str(wget_path), - version="9.9.9", - binprovider="env", - binproviders="env,apt,brew", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("wget", machine=machine, binproviders="env,apt,brew") + installed_abspath = binary.abspath + installed_version = binary.version + installed_provider = binary.binprovider bus = create_bus(name="test_binary_event_reuses_existing_installed_binary_row") BinaryCacheService(bus, backend=ArchiveBoxDBBinaryCacheBackend()) @@ -754,7 +665,7 @@ def test_binary_event_reuses_existing_installed_binary_row(): binproviders=binary.binproviders, extra_context={ "plugin_name": "wget", - "output_dir": "/tmp/wget", + "output_dir": str(binary.output_dir), }, ) @@ -767,7 +678,7 @@ def test_binary_event_reuses_existing_installed_binary_row(): binary.refresh_from_db() assert Binary.objects.filter(machine=machine, name="wget").count() == 1 assert binary.status == Binary.StatusChoices.INSTALLED - assert binary.abspath == str(wget_path) - assert binary.version == "9.9.9" - assert binary.binprovider == "env" + assert binary.abspath == installed_abspath + assert binary.version == installed_version + assert binary.binprovider == installed_provider assert binary.binproviders == "env,apt,brew" diff --git a/archivebox/tests/test_binary_service.py b/archivebox/tests/test_binary_service.py index cdf3f2e2..ac49a46d 100644 --- a/archivebox/tests/test_binary_service.py +++ b/archivebox/tests/test_binary_service.py @@ -1,4 +1,3 @@ -import json import sys import uuid import asyncio @@ -8,7 +7,7 @@ import pytest from django.utils import timezone from archivebox.machine.models import Binary, Machine, Process -from archivebox.tests.conftest import parse_jsonl_output, run_archivebox_cmd +from archivebox.tests.conftest import run_archivebox_cmd from archivebox.tests.test_orm_helpers import use_archivebox_db pytestmark = pytest.mark.django_db(transaction=True) @@ -22,7 +21,25 @@ def _runtime_env(data_dir: Path, *, lib_dir: Path | None = None, **extra: str) - } -def test_binary_request_preserves_raw_overrides_in_db_while_using_native_event(monkeypatch): +def _run_real_binary_state_machine(data_dir: Path, *, name: str, binproviders: str, env: dict[str, str]): + """Run a real Binary model through its abxpkg-backed state machine.""" + script = ( + "from archivebox.machine.models import Binary, Machine; " + f"binary = Binary.objects.create(machine=Machine.current(), name={name!r}, binproviders={binproviders!r}, status=Binary.StatusChoices.QUEUED); " + "assert binary.tick_claimed(lock_seconds=600); " + "print('BINARY_STATE_MACHINE_E2E_DONE')" + ) + return run_archivebox_cmd( + ["shell", "-c", script], + cwd=data_dir, + timeout=120, + env=env, + default_cli_env=True, + disable_extractors=True, + ) + + +def test_binary_request_preserves_raw_overrides_in_db_while_using_native_event(): from abxpkg.binary_service import BinaryCacheService, BinaryEvent, BinaryRequestEvent, BinaryService from abx_dl.orchestrator import create_bus from archivebox.services.binary_service import ArchiveBoxDBBinaryCacheBackend @@ -34,7 +51,6 @@ def test_binary_request_preserves_raw_overrides_in_db_while_using_native_event(m "module_name": "imagesize", }, } - monkeypatch.setenv("PYTHON3_BINARY", sys.executable) binary = Binary.objects.create( machine=machine, name="python3", @@ -85,26 +101,11 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized name = "python" provider_bin_dir = initialized_archive / "lib" / "env" / "bin" runtime_env = _runtime_env(initialized_archive) - request_record = { - "type": "BinaryRequest", - "name": name, - "binproviders": "env", - } - - _cmd_result = run_archivebox_cmd( - ["run"], - cwd=initialized_archive, - stdin=json.dumps(request_record) + "\n", - timeout=120, - env=runtime_env, - default_cli_env=True, - disable_extractors=True, - ) + _cmd_result = _run_real_binary_state_machine(initialized_archive, name=name, binproviders="env", env=runtime_env) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr - output_records = parse_jsonl_output(stdout) - assert any(record["type"] == "BinaryRequest" and record["name"] == name for record in output_records) + assert "BINARY_STATE_MACHINE_E2E_DONE" in stdout with use_archivebox_db(initialized_archive): binary = Binary.objects.get(name=name) @@ -173,10 +174,15 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized lib_dir=changed_lib_dir, ) + with use_archivebox_db(initialized_archive): + Binary.objects.get(pk=first_binary_id).update_and_requeue( + status=Binary.StatusChoices.QUEUED, + retry_at=None, + ) + _cmd_result = run_archivebox_cmd( - ["run"], + ["run", f"--binary-id={first_binary_id}"], cwd=initialized_archive, - stdin=json.dumps(request_record) + "\n", timeout=120, env=changed_runtime_env, default_cli_env=True, @@ -200,19 +206,11 @@ def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_res provider_bin_dir = initialized_archive / "lib" / "pip" / "venv" / "bin" runtime_env = _runtime_env(initialized_archive) - _cmd_result = run_archivebox_cmd( - ["run"], - cwd=initialized_archive, - stdin=json.dumps({"type": "BinaryRequest", "name": name, "binproviders": "env"}) + "\n", - timeout=120, - env=runtime_env, - default_cli_env=True, - disable_extractors=True, - ) + _cmd_result = _run_real_binary_state_machine(initialized_archive, name=name, binproviders="env", env=runtime_env) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - assert returncode == 0, stderr - assert any(record["type"] == "BinaryRequest" and record["name"] == name for record in parse_jsonl_output(stdout)), stdout + stderr + assert returncode != 0, stdout + stderr + assert "Binary http installation failed" in stderr with use_archivebox_db(initialized_archive): queued = Binary.objects.get(name=name) diff --git a/archivebox/tests/test_cli_add.py b/archivebox/tests/test_cli_add.py index dbddd471..63c50aaf 100644 --- a/archivebox/tests/test_cli_add.py +++ b/archivebox/tests/test_cli_add.py @@ -6,7 +6,6 @@ Verify add creates snapshots in DB, crawls, source files, and archive directorie import os import json -import time from pathlib import Path import pytest @@ -18,11 +17,8 @@ from archivebox.machine.models import Process from archivebox.tests.conftest import ( cli_env, find_snapshot_dir, - get_free_port, run_archivebox_cmd, run_queued_crawls, - start_archivebox_server, - stop_server, resolve_abxpkg_chrome_env, ) @@ -158,43 +154,18 @@ IMPORT_FORMAT_ENV = { } -def wait_for_import_processing(cwd: Path, expected_urls: set[str], *, timeout: float = 120.0) -> None: - import time - - deadline = time.time() + timeout - counts = {url: 0 for url in expected_urls} - while time.time() < deadline: - with use_archivebox_db(cwd): - rows = list(Snapshot.objects.filter(url__in=expected_urls).values_list("url", flat=True)) - counts = {url: 0 for url in expected_urls} - for url in rows: - counts[url] += 1 - if all(count >= 1 for count in counts.values()): - return - time.sleep(1) - raise AssertionError(f"timed out waiting for import crawl processing to start, got counts={counts}") - - -def wait_for_expected_import_snapshots(cwd: Path, expected_urls: set[str], *, timeout: float = 180.0) -> None: - import time - +def assert_expected_import_snapshots(cwd: Path, expected_urls: set[str]) -> None: allowed_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED} - deadline = time.time() + timeout - while time.time() < deadline: - with use_archivebox_db(cwd): - rows = list(Snapshot.objects.filter(url__in=expected_urls).values_list("url", "status")) - counts = {url: 0 for url in expected_urls} - bad_statuses = [] - for url, status in rows: - counts[url] += 1 - if status not in allowed_statuses: - bad_statuses.append((url, status)) - if all(count == 1 for count in counts.values()) and not bad_statuses: - return - time.sleep(1) - raise AssertionError( - f"timed out waiting for one queued/started/sealed snapshot per URL, got counts={counts}, bad_statuses={bad_statuses}", - ) + with use_archivebox_db(cwd): + rows = list(Snapshot.objects.filter(url__in=expected_urls).values_list("url", "status")) + counts = {url: 0 for url in expected_urls} + bad_statuses = [] + for url, status in rows: + counts[url] += 1 + if status not in allowed_statuses: + bad_statuses.append((url, status)) + assert all(count == 1 for count in counts.values()), counts + assert not bad_statuses, bad_statuses def malicious_add_inputs(tmp_path: Path, *, safe_url: str) -> tuple[list[str], Path]: @@ -263,8 +234,7 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial """`archivebox add < import-file` should normalize rich import formats before crawling URLs.""" import_files = write_import_format_files(initialized_archive) expected_urls = {case["url"] for case in IMPORT_FORMAT_EXPECTATIONS.values()} - port = get_free_port() - env = cli_env(port=port, server=True, **IMPORT_FORMAT_ENV) + env = cli_env(**IMPORT_FORMAT_ENV) for import_path in import_files.values(): source_text = import_path.read_text(encoding="utf-8") @@ -282,40 +252,26 @@ def test_add_stdin_import_formats_preserve_metadata_and_crawl_inner_urls(initial assert crawl.snapshot_set.count() == 0 assert crawl.urls == source_text - try: - start_archivebox_server(initialized_archive, env=env, port=port) - deadline = time.time() + 120 - root_counts = {} - while time.time() < deadline: - with use_archivebox_db(initialized_archive): - root_counts = { - str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all() - } - if root_counts and all(count == 1 for count in root_counts.values()): - break - time.sleep(1) - assert root_counts and all(count == 1 for count in root_counts.values()), root_counts - with use_archivebox_db(initialized_archive): - for crawl in Crawl.objects.all(): - root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL) - root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") - assert root_input == crawl.urls - wait_for_import_processing(initialized_archive, expected_urls) - stop_server(initialized_archive) - start_archivebox_server(initialized_archive, env=env, port=port) - wait_for_expected_import_snapshots(initialized_archive, expected_urls) + run_queued_crawls(initialized_archive, env=env, timeout=240) + with use_archivebox_db(initialized_archive): + root_counts = {str(crawl.id): crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).count() for crawl in Crawl.objects.all()} + assert root_counts and all(count == 1 for count in root_counts.values()), root_counts + with use_archivebox_db(initialized_archive): + for crawl in Crawl.objects.all(): + root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL) + root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") + assert root_input == crawl.urls + assert_expected_import_snapshots(initialized_archive, expected_urls) - list_result = run_archivebox_cmd( - ["list", "--json"], - cwd=initialized_archive, - env=env, - timeout=60, - ) - assert list_result.returncode == 0, list_result.stderr or list_result.stdout - for expected_url in expected_urls: - assert expected_url in list_result.stdout - finally: - stop_server(initialized_archive) + list_result = run_archivebox_cmd( + ["list", "--json"], + cwd=initialized_archive, + env=env, + timeout=60, + ) + assert list_result.returncode == 0, list_result.stderr or list_result.stdout + for expected_url in expected_urls: + assert expected_url in list_result.stdout with use_archivebox_db(initialized_archive): crawls = list(Crawl.objects.order_by("created_at")) @@ -345,8 +301,7 @@ def test_add_rejects_file_path_and_shell_injection_payloads(initialized_archive) """CLI add must not turn user-supplied local paths or shell payloads into snapshots.""" safe_url = "https://example.com/?archivebox-cli-security=1" inputs, canary = malicious_add_inputs(initialized_archive, safe_url=safe_url) - port = get_free_port() - env = cli_env(port=port, server=True, **IMPORT_FORMAT_ENV) + env = cli_env(**IMPORT_FORMAT_ENV) result = run_archivebox_cmd( ["add", "--bg", "--depth=0", "--tag=cli-security"], @@ -357,11 +312,8 @@ def test_add_rejects_file_path_and_shell_injection_payloads(initialized_archive) ) assert result.returncode == 0, result.stderr or result.stdout - try: - start_archivebox_server(initialized_archive, env=env, port=port) - wait_for_expected_import_snapshots(initialized_archive, {safe_url}, timeout=120) - finally: - stop_server(initialized_archive) + run_queued_crawls(initialized_archive, env=env, timeout=120) + assert_expected_import_snapshots(initialized_archive, {safe_url}) assert_no_file_or_shell_payload_snapshots(initialized_archive, canary=canary) with use_archivebox_db(initialized_archive): diff --git a/archivebox/tests/test_cli_archiveresult.py b/archivebox/tests/test_cli_archiveresult.py index 681a8a1f..3ef1d83b 100644 --- a/archivebox/tests/test_cli_archiveresult.py +++ b/archivebox/tests/test_cli_archiveresult.py @@ -126,21 +126,31 @@ class TestArchiveResultCreate: assert "Snapshot" in types assert "ArchiveResult" in types - def test_create_pass_through_only_when_no_snapshots(self, initialized_archive): - """Only pass-through records but no new snapshots returns success.""" - crawl_record = {"type": "Crawl", "id": "fake-id", "urls": "https://example.com"} - - _cmd_result = run_archivebox_cmd( - ["archiveresult", "create"], - stdin=json.dumps(crawl_record), + def test_create_passes_through_cli_crawl_when_no_snapshots(self, initialized_archive): + """A real Crawl with no Snapshot input passes through successfully.""" + crawl_result = run_archivebox_cmd( + ["crawl", "create", create_test_url()], cwd=initialized_archive, default_cli_env=True, disable_extractors=True, ) - _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode + assert crawl_result.returncode == 0, crawl_result.stderr + crawl_record = parse_jsonl_output(crawl_result.stdout)[0] + + _cmd_result = run_archivebox_cmd( + ["archiveresult", "create"], + stdin=crawl_result.stdout, + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert code == 0 assert "Passed through" in stderr + records = parse_jsonl_output(stdout) + assert len(records) == 1 + assert records[0]["id"] == crawl_record["id"] class TestArchiveResultList: diff --git a/archivebox/tests/test_cli_binary.py b/archivebox/tests/test_cli_binary.py index 480728ea..ea9a4a59 100644 --- a/archivebox/tests/test_cli_binary.py +++ b/archivebox/tests/test_cli_binary.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox binary command. - -TODO: expand beyond command discovery into create/list/update/delete behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_config.py b/archivebox/tests/test_cli_config.py index 09ca1342..298c3174 100644 --- a/archivebox/tests/test_cli_config.py +++ b/archivebox/tests/test_cli_config.py @@ -158,6 +158,28 @@ def test_config_search_finds_keys(initialized_archive): assert "TIMEOUT" in result.stdout +def test_config_search_finds_plugin_options(initialized_archive): + """Test that config --search finds plugin keys and descriptions.""" + + result = run_archivebox_cmd( + ["config", "--search", "wget"], + ) + + assert result.returncode == 0 + assert "WGET_BINARY" in result.stdout + + +def test_config_search_finds_core_aliases(initialized_archive): + """Test that config --search finds core options by partial alias.""" + + result = run_archivebox_cmd( + ["config", "--search", "URL_BLACK"], + ) + + assert result.returncode == 0 + assert "URL_DENYLIST" in result.stdout + + def test_config_preserves_existing_values(initialized_archive): """Test that setting new values preserves existing ones.""" diff --git a/archivebox/tests/test_cli_crawl.py b/archivebox/tests/test_cli_crawl.py index 850f40ef..268059cc 100644 --- a/archivebox/tests/test_cli_crawl.py +++ b/archivebox/tests/test_cli_crawl.py @@ -106,11 +106,18 @@ class TestCrawlCreate: records = parse_jsonl_output(stdout) assert "test-tag" in records[0].get("tags_str", "") - def test_create_pass_through_other_types(self, initialized_archive): - """Pass-through records of other types unchanged.""" - tag_record = {"type": "Tag", "id": "fake-tag-id", "name": "test"} + def test_create_passes_through_tag_emitted_by_cli(self, initialized_archive): + """A real Tag emitted by the CLI remains available to the next stage.""" + tag_result = run_archivebox_cmd( + ["tag", "create", "crawl-input-tag"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert tag_result.returncode == 0, tag_result.stderr + tag_record = parse_jsonl_output(tag_result.stdout)[0] url = create_test_url() - stdin = json.dumps(tag_record) + "\n" + json.dumps({"url": url}) + stdin = tag_result.stdout + json.dumps({"url": url}) + "\n" _cmd_result = run_archivebox_cmd( ["crawl", "create"], @@ -124,10 +131,8 @@ class TestCrawlCreate: assert code == 0 records = parse_jsonl_output(stdout) - # Should have both the passed-through Tag and the new Crawl - types = [r.get("type") for r in records] - assert "Tag" in types - assert "Crawl" in types + assert any(record.get("type") == "Tag" and record["id"] == tag_record["id"] for record in records) + assert any(record.get("type") == "Crawl" and url in record["urls"] for record in records) def test_create_pass_through_existing_crawl(self, initialized_archive): """Existing Crawl records (with id) are passed through.""" diff --git a/archivebox/tests/test_cli_extract_input.py b/archivebox/tests/test_cli_extract_input.py index 1ceb4df1..e1424543 100644 --- a/archivebox/tests/test_cli_extract_input.py +++ b/archivebox/tests/test_cli_extract_input.py @@ -1,7 +1,6 @@ """Tests for archivebox extract input handling and pipelines.""" import subprocess -import json import pytest @@ -219,15 +218,19 @@ def test_extract_stdin_jsonl_input(initialized_archive): env = cli_env(PLUGINS="wget,title") create_extract_snapshot(initialized_archive, env) - with use_archivebox_db(initialized_archive): - snapshot_id = Snapshot.objects.values_list("id", flat=True).first() - - jsonl_input = json.dumps({"type": "Snapshot", "id": str(snapshot_id)}) + "\n" + list_result = run_archivebox_cmd( + ["snapshot", "list", "--url__icontains=example.com"], + cwd=initialized_archive, + env=env, + check=True, + ) + snapshot_record = next(record for record in parse_jsonl_output(list_result.stdout) if record.get("type") == "Snapshot") + snapshot_id = snapshot_record["id"] result = run_archivebox_cmd( ["extract", "--plugins=wget,title"], cwd=initialized_archive, - input=jsonl_input, + input=list_result.stdout, env=env, timeout=90, ) diff --git a/archivebox/tests/test_cli_list.py b/archivebox/tests/test_cli_list.py index bb791cef..eff87c18 100644 --- a/archivebox/tests/test_cli_list.py +++ b/archivebox/tests/test_cli_list.py @@ -1,17 +1,17 @@ -#!/usr/bin/env python3 """ Tests for archivebox list command. Verify list emits snapshot JSONL and applies the documented filters. """ import json -import sys import pytest +from django.contrib.auth import get_user_model from django.db import connection from django.utils import timezone from archivebox.core.models import Snapshot +from archivebox.cli.archivebox_snapshot import iter_snapshot_json from archivebox.tests.conftest import create_test_url, parse_jsonl_output, run_archivebox_cmd, run_queued_crawls, cli_env from archivebox.tests.test_orm_helpers import use_archivebox_db @@ -19,102 +19,128 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db pytestmark = pytest.mark.django_db(transaction=True) -class CountingStdout: - encoding = "utf-8" - - def __init__(self): - self.rows = 0 - self._pending = "" - - def isatty(self): - return False - - def write(self, text): - self._pending += text - lines = self._pending.split("\n") - self._pending = lines.pop() - self.rows += sum(1 for line in lines if line.startswith("{")) - return len(text) - - def flush(self): - return None - - -def test_list_limit_zero_streams_one_million_snapshots_without_materializing(admin_user, monkeypatch): - """Regression: archivebox list --limit=0 must stream unbounded result sets.""" - from archivebox.cli.archivebox_snapshot import list_snapshots +def test_streaming_json_matches_snapshot_serializer(initialized_archive): from archivebox.crawls.models import Crawl - crawl = Crawl.objects.create( - urls="https://example.com", - created_by=admin_user, - status=Crawl.StatusChoices.SEALED, - retry_at=None, - ) - now = timezone.now().isoformat() - with connection.cursor() as cursor: - cursor.execute( - """ - WITH RECURSIVE seq(n) AS ( - SELECT 1 - UNION ALL - SELECT n + 1 FROM seq WHERE n < 1000000 + with use_archivebox_db(initialized_archive): + user = get_user_model().objects.create_user(username="streaming-json-parity") + crawl = Crawl.objects.create( + urls="https://example.com/a\nhttps://example.com/b", + created_by=user, + status=Crawl.StatusChoices.SEALED, + retry_at=None, + ) + populated = Snapshot.objects.create( + crawl=crawl, + url="https://example.com/a", + timestamp="20260721220000000000000000000001", + title="Populated title", + status=Snapshot.StatusChoices.SEALED, + retry_at=None, + output_size=42, + ) + populated.save_tags(["Êclair", "Zulu", "alpha"]) + empty = Snapshot.objects.create( + crawl=crawl, + url="https://example.com/b", + timestamp="20260721220000000000000000000002", + title=None, + status=Snapshot.StatusChoices.QUEUED, + output_size=0, + ) + queryset = Snapshot.objects.filter(id__in=(populated.id, empty.id)).order_by("url") + + expected = [snapshot.to_json() for snapshot in queryset.prefetch_related("tags")] + actual = list(iter_snapshot_json(queryset)) + + assert actual == expected + assert [record["url"] for record in actual] == ["https://example.com/a", "https://example.com/b"] + + +def test_list_limit_zero_streams_one_million_snapshots_without_materializing(initialized_archive, tmp_path): + """Regression: archivebox list --limit=0 must stream unbounded result sets.""" + from archivebox.crawls.models import Crawl + + with use_archivebox_db(initialized_archive): + user = get_user_model().objects.create_user(username="million-snapshot-list") + crawl = Crawl.objects.create( + urls="https://example.com", + created_by=user, + status=Crawl.StatusChoices.SEALED, + retry_at=None, + ) + now = timezone.now().isoformat() + with connection.cursor() as cursor: + cursor.execute( + """ + WITH RECURSIVE seq(n) AS ( + SELECT 1 + UNION ALL + SELECT n + 1 FROM seq WHERE n < 1000000 + ) + INSERT INTO core_snapshot ( + id, + url, + timestamp, + title, + bookmarked_at, + created_at, + modified_at, + downloaded_at, + fs_version, + crawl_id, + config, + current_step, + depth, + notes, + num_uses_failed, + num_uses_succeeded, + retry_at, + status, + delete_at, + output_size, + parent_snapshot_id + ) + SELECT + lower(hex(randomblob(16))), + 'https://example.com/page-' || n, + printf('9%031d', n), + '', + %s, + %s, + %s, + NULL, + '0.9.0', + %s, + '{}', + 0, + 0, + '', + 0, + 0, + NULL, + 'sealed', + NULL, + 0, + NULL + FROM seq + """, + [now, now, now, str(crawl.id).replace("-", "")], ) - INSERT INTO core_snapshot ( - id, - url, - timestamp, - title, - bookmarked_at, - created_at, - modified_at, - downloaded_at, - fs_version, - crawl_id, - config, - current_step, - depth, - notes, - num_uses_failed, - num_uses_succeeded, - retry_at, - status, - delete_at, - output_size, - parent_snapshot_id - ) - SELECT - lower(hex(randomblob(16))), - 'https://example.com/page-' || n, - printf('9%031d', n), - '', - %s, - %s, - %s, - NULL, - '0.9.0', - %s, - '{}', - 0, - 0, - '', - 0, - 0, - NULL, - 'sealed', - NULL, - 0, - NULL - FROM seq - """, - [now, now, now, str(crawl.id).replace("-", "")], + + output_path = tmp_path / "million-snapshots.jsonl" + with output_path.open("w") as stdout: + result = run_archivebox_cmd( + ["list", "--limit=0"], + cwd=initialized_archive, + stdout=stdout, + default_cli_env=True, + disable_extractors=True, ) - stdout = CountingStdout() - monkeypatch.setattr(sys, "stdout", stdout) - - assert list_snapshots(limit=0) == 0 - assert stdout.rows == 1000000 + assert result.returncode == 0, result.stderr + with output_path.open() as stdout: + assert sum(1 for line in stdout if line.startswith("{")) == 1000000 def test_list_outputs_existing_snapshots_as_jsonl(initialized_archive): @@ -127,6 +153,8 @@ def test_list_outputs_existing_snapshots_as_jsonl(initialized_archive): check=True, ) run_queued_crawls(initialized_archive, env) + with use_archivebox_db(initialized_archive): + Snapshot.objects.get(url="https://example.com").save_tags(["z-tag", "a-tag"]) result = run_archivebox_cmd( ["list"], @@ -135,10 +163,12 @@ def test_list_outputs_existing_snapshots_as_jsonl(initialized_archive): rows = parse_jsonl_output(result.stdout) urls = {row["url"] for row in rows} + rows_by_url = {row["url"]: row for row in rows} assert result.returncode == 0, result.stderr assert "https://example.com" in urls assert "https://iana.org" in urls + assert rows_by_url["https://example.com"]["tags"] == "a-tag,z-tag" def test_list_filters_by_url_icontains(initialized_archive): diff --git a/archivebox/tests/test_cli_machine.py b/archivebox/tests/test_cli_machine.py index 602ea12d..5e445998 100644 --- a/archivebox/tests/test_cli_machine.py +++ b/archivebox/tests/test_cli_machine.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox machine command. - -TODO: expand beyond command discovery into list/filter behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_mcp.py b/archivebox/tests/test_cli_mcp.py index 82b2c97b..4090ae6f 100644 --- a/archivebox/tests/test_cli_mcp.py +++ b/archivebox/tests/test_cli_mcp.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox mcp command. - -TODO: expand beyond command discovery into JSON-RPC stdio behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_oneshot.py b/archivebox/tests/test_cli_oneshot.py new file mode 100644 index 00000000..84c1376f --- /dev/null +++ b/archivebox/tests/test_cli_oneshot.py @@ -0,0 +1,19 @@ +from importlib.metadata import version + +from archivebox.tests.conftest import run_archivebox_cmd + + +def test_oneshot_runs_abx_dl_through_abxpkg_env_projection(tmp_path): + lib_dir = tmp_path / "lib" + + result = run_archivebox_cmd( + ["oneshot", "--version"], + cwd=tmp_path, + env={"ABXPKG_LIB_DIR": str(lib_dir)}, + ) + + abx_dl_projection = lib_dir / "env" / "bin" / "abx-dl" + assert result.returncode == 0, result.stderr + assert version("abx-dl") in result.stdout + assert abx_dl_projection.is_symlink() + assert abx_dl_projection.resolve().is_file() diff --git a/archivebox/tests/test_cli_persona.py b/archivebox/tests/test_cli_persona.py index 6f45cc54..6379facc 100644 --- a/archivebox/tests/test_cli_persona.py +++ b/archivebox/tests/test_cli_persona.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox persona command. - -TODO: expand beyond command discovery into create/list/update/delete behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_piping.py b/archivebox/tests/test_cli_piping.py index 4c857651..a274db3a 100644 --- a/archivebox/tests/test_cli_piping.py +++ b/archivebox/tests/test_cli_piping.py @@ -6,9 +6,12 @@ This file covers both: - subprocess integration for the supported records `archivebox run` consumes """ +import os +import pty import sys import uuid -from io import StringIO +from importlib.resources import files +from pathlib import Path import pytest @@ -33,18 +36,33 @@ PIPE_TEST_ENV = { } -class MockTTYStringIO(StringIO): - def __init__(self, initial_value: str = "", *, is_tty: bool): - super().__init__(initial_value) - self._is_tty = is_tty +def run_real_txt_parser(tmp_path, text): + """Run the shipped text parser and return its real snapshot output directory.""" + from archivebox.plugins.hooks import run_hook - def isatty(self) -> bool: - return self._is_tty + snap_dir = tmp_path / "parser-snapshot" + staticfile_dir = snap_dir / "staticfile" + output_dir = snap_dir / "parse_txt_urls" + staticfile_dir.mkdir(parents=True) + output_dir.mkdir(parents=True) + (staticfile_dir / "input.txt").write_text(text, encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py"))) + process = run_hook( + hook_path, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="file:///input.txt", + depth=0, + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + return snap_dir def test_parse_line_accepts_supported_piping_inputs(): """The JSONL parser should normalize the input forms CLI pipes accept.""" - from archivebox.misc.jsonl import TYPE_CRAWL, TYPE_SNAPSHOT, parse_line + from archivebox.misc.jsonl import TYPE_SNAPSHOT, parse_line assert parse_line("") is None assert parse_line(" ") is None @@ -57,27 +75,8 @@ def test_parse_line_accepts_supported_piping_inputs(): assert parse_line("file:///tmp/example.txt") is None - snapshot_json = parse_line('{"type":"Snapshot","url":"https://example.com","tags":"tag1,tag2"}') - assert snapshot_json is not None - assert snapshot_json["type"] == TYPE_SNAPSHOT - assert snapshot_json["tags"] == "tag1,tag2" - crawl_json = parse_line('{"type":"Crawl","id":"abc123","urls":"https://example.com","max_depth":1}') - assert crawl_json is not None - assert crawl_json["type"] == TYPE_CRAWL - assert crawl_json["id"] == "abc123" - assert crawl_json["max_depth"] == 1 - - snapshot_id = "01234567-89ab-cdef-0123-456789abcdef" - parsed_id = parse_line(snapshot_id) - assert parsed_id == {"type": TYPE_SNAPSHOT, "id": snapshot_id} - - compact_snapshot_id = "0123456789abcdef0123456789abcdef" - compact_parsed_id = parse_line(compact_snapshot_id) - assert compact_parsed_id == {"type": TYPE_SNAPSHOT, "id": compact_snapshot_id} - - -def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(tmp_path): +def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(tmp_path, initialized_archive): """Piping helpers should consume args, structured JSONL, and pass-through records.""" from archivebox.misc.jsonl import TYPE_CRAWL, read_args_or_stdin @@ -88,80 +87,67 @@ def test_read_args_or_stdin_handles_args_stdin_and_mixed_jsonl(tmp_path): local_file.write_text("https://from-file-arg.example\n") assert list(read_args_or_stdin((str(local_file),))) == [] - stdin_records = list( - read_args_or_stdin( - (), - stream=MockTTYStringIO( - "https://plain-url.com\n" - '{"type":"Snapshot","url":"https://jsonl-url.com","tags":"test"}\n' - '{"type":"Tag","id":"tag-1","name":"example"}\n' - "01234567-89ab-cdef-0123-456789abcdef\n" - "not valid json\n", - is_tty=False, - ), - ), + snapshot_result = run_archivebox_cmd( + ["snapshot", "create", "--tag=test", "https://jsonl-url.com"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + check=True, ) - assert len(stdin_records) == 4 - assert stdin_records[0]["url"] == "https://plain-url.com" - assert stdin_records[1]["url"] == "https://jsonl-url.com" - assert stdin_records[1]["tags"] == "test" - assert stdin_records[2]["type"] == "Tag" - assert stdin_records[2]["name"] == "example" - assert stdin_records[3]["id"] == "01234567-89ab-cdef-0123-456789abcdef" - - crawl_records = list( - read_args_or_stdin( - (), - stream=MockTTYStringIO( - '{"type":"Crawl","id":"crawl-1","urls":"https://example.com\\nhttps://foo.com"}\n', - is_tty=False, - ), - ), + crawl_result = run_archivebox_cmd( + ["crawl", "create", "https://crawl-url.com"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + check=True, ) - assert len(crawl_records) == 1 - assert crawl_records[0]["type"] == TYPE_CRAWL - assert crawl_records[0]["id"] == "crawl-1" + snapshot_record = next(record for record in parse_jsonl_output(snapshot_result.stdout) if record.get("type") == "Snapshot") - tty_records = list(read_args_or_stdin((), stream=MockTTYStringIO("https://example.com", is_tty=True))) - assert tty_records == [] + read_fd, write_fd = os.pipe() + os.write( + write_fd, + f"https://plain-url.com\n{snapshot_result.stdout}{crawl_result.stdout}{snapshot_record['id']}\nnot valid json\n".encode(), + ) + os.close(write_fd) + with os.fdopen(read_fd, encoding="utf-8") as pipe_stream: + assert pipe_stream.isatty() is False + stdin_records = list(read_args_or_stdin((), stream=pipe_stream)) + assert any(record.get("url") == "https://plain-url.com" for record in stdin_records) + assert any(record.get("type") == "Snapshot" and record.get("id") == snapshot_record["id"] for record in stdin_records) + assert any(record.get("type") == TYPE_CRAWL and record.get("urls") == "https://crawl-url.com" for record in stdin_records) + assert any( + record.get("type") == "Snapshot" and record.get("id") == snapshot_record["id"] and len(record) == 2 for record in stdin_records + ) + + master_fd, slave_fd = pty.openpty() + try: + with os.fdopen(slave_fd, encoding="utf-8") as tty_stream: + assert tty_stream.isatty() is True + assert list(read_args_or_stdin((), stream=tty_stream)) == [] + finally: + os.close(master_fd) def test_collect_urls_from_plugins_reads_only_parser_outputs(tmp_path): """Parser extractor `urls.jsonl` outputs should be discoverable for recursive piping.""" from archivebox.plugins.hooks import collect_urls_from_plugins - (tmp_path / "wget").mkdir() - (tmp_path / "wget" / "urls.jsonl").write_text( - '{"url":"https://wget-link-1.com"}\n{"url":"https://wget-link-2.com"}\n', - encoding="utf-8", - ) - (tmp_path / "parse_html_urls").mkdir() - (tmp_path / "parse_html_urls" / "urls.jsonl").write_text( - '{"url":"https://html-link-1.com"}\n{"url":"https://html-link-2.com","title":"HTML Link 2"}\n', - encoding="utf-8", - ) - (tmp_path / "screenshot").mkdir() + snap_dir = run_real_txt_parser(tmp_path, "https://html-link-1.com https://html-link-2.com") + (snap_dir / "screenshot").mkdir() - urls = collect_urls_from_plugins(tmp_path) - assert len(urls) == 4 - assert {url["plugin"] for url in urls} == {"wget", "parse_html_urls"} - titled = [url for url in urls if url.get("title") == "HTML Link 2"] - assert len(titled) == 1 - assert titled[0]["url"] == "https://html-link-2.com" + urls = collect_urls_from_plugins(snap_dir) + assert {url["url"] for url in urls} == {"https://html-link-1.com", "https://html-link-2.com"} + assert {url["plugin"] for url in urls} == {"parse_txt_urls"} - assert collect_urls_from_plugins(tmp_path / "nonexistent") == [] + assert collect_urls_from_plugins(snap_dir / "nonexistent") == [] def test_collect_urls_from_plugins_trims_markdown_suffixes(tmp_path): from archivebox.plugins.hooks import collect_urls_from_plugins - (tmp_path / "parse_html_urls").mkdir() - (tmp_path / "parse_html_urls" / "urls.jsonl").write_text( - '{"url":"https://docs.sweeting.me/s/youtube-favorites)**"}\n', - encoding="utf-8", - ) + snap_dir = run_real_txt_parser(tmp_path, "[favorites](https://docs.sweeting.me/s/youtube-favorites)**") - urls = collect_urls_from_plugins(tmp_path) + urls = collect_urls_from_plugins(snap_dir) assert len(urls) == 1 assert urls[0]["url"] == "https://docs.sweeting.me/s/youtube-favorites" @@ -169,13 +155,9 @@ def test_collect_urls_from_plugins_trims_markdown_suffixes(tmp_path): def test_collect_urls_from_plugins_trims_trailing_punctuation(tmp_path): from archivebox.plugins.hooks import collect_urls_from_plugins - (tmp_path / "parse_html_urls").mkdir() - (tmp_path / "parse_html_urls" / "urls.jsonl").write_text( - ('{"url":"https://github.com/ArchiveBox/ArchiveBox."}\n{"url":"https://github.com/abc?abc#234234?."}\n'), - encoding="utf-8", - ) + snap_dir = run_real_txt_parser(tmp_path, "https://github.com/ArchiveBox/ArchiveBox. https://github.com/abc?abc#234234?.") - urls = collect_urls_from_plugins(tmp_path) + urls = collect_urls_from_plugins(snap_dir) assert [url["url"] for url in urls] == [ "https://github.com/ArchiveBox/ArchiveBox", "https://github.com/abc?abc#234234", diff --git a/archivebox/tests/test_cli_pluginmap.py b/archivebox/tests/test_cli_pluginmap.py index d7266ec3..37ddc097 100644 --- a/archivebox/tests/test_cli_pluginmap.py +++ b/archivebox/tests/test_cli_pluginmap.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox pluginmap command. - -TODO: expand beyond command discovery into quiet/event output behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_process.py b/archivebox/tests/test_cli_process.py index 1968fc50..3a5b2706 100644 --- a/archivebox/tests/test_cli_process.py +++ b/archivebox/tests/test_cli_process.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox process command. - -TODO: expand beyond command discovery into list/filter behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_cli_run.py b/archivebox/tests/test_cli_run.py index d9d5a370..08258d6b 100644 --- a/archivebox/tests/test_cli_run.py +++ b/archivebox/tests/test_cli_run.py @@ -7,14 +7,12 @@ Tests cover: - pass-through output (for chaining) """ -import json import os import signal import subprocess -import sys -import time import pytest +import psutil from archivebox.tests.conftest import ( cleanup_process_group, @@ -22,9 +20,8 @@ from archivebox.tests.conftest import ( run_archivebox_cmd, parse_jsonl_output, create_test_url, - create_test_crawl_json, - create_test_snapshot_json, pid_is_alive, + wait_for_log, wait_for_pid_to_disappear, ) @@ -48,133 +45,72 @@ def _install_real_chrome_for_test(data_dir, env, *, isolation): @pytest.mark.django_db(transaction=True) -@pytest.mark.timeout(90) -def test_cli_run_signal_cleans_background_hook_process_group(initialized_archive): +@pytest.mark.timeout(660) +def test_cli_run_signal_cleans_real_chrome_hook_process_group(initialized_archive, recursive_test_site): + from archivebox.core.models import Snapshot + from archivebox.tests.test_orm_helpers import use_archivebox_db - plugins_root = initialized_archive / "runtime_plugins" - plugin_dir = plugins_root / "cancel_group" - plugin_dir.mkdir(parents=True) - daemon_hook = plugin_dir / "on_CrawlSetup__10_daemon.daemon.bg.sh" - foreground_hook = plugin_dir / "on_CrawlSetup__20_foreground.sh" - daemon_hook.write_text( - "\n".join( - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'test_dir="${LEAK_TEST_DIR:?}"', - "sleep 600 &", - 'echo $$ > "$test_dir/daemon.pid"', - 'echo $! > "$test_dir/daemon-child.pid"', - 'echo ready > "$test_dir/daemon.ready"', - "trap 'echo cleaned > \"$test_dir/daemon.cleaned\"; exit 0' TERM INT", - "wait", - "", - ], - ), - ) - foreground_hook.write_text( - "\n".join( - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'test_dir="${LEAK_TEST_DIR:?}"', - 'echo $$ > "$test_dir/foreground.pid"', - 'echo ready > "$test_dir/foreground.ready"', - "trap 'echo cleaned > \"$test_dir/foreground.cleaned\"; exit 0' TERM INT", - "while true; do sleep 1; done", - "", - ], - ), - ) - daemon_hook.chmod(0o755) - foreground_hook.chmod(0o755) - - leak_test_dir = initialized_archive / "leak-check" - leak_test_dir.mkdir() - env = os.environ.copy() - env.update( - { - "ABX_PLUGINS_DIR": str(plugins_root), - "LEAK_TEST_DIR": str(leak_test_dir), - "PLUGINS": "cancel_group", - "TIMEOUT": "30", - "USE_COLOR": "false", - "SHOW_PROGRESS": "false", - }, - ) + env = cli_env(live=True, PLUGINS="chrome", CHROME_ISOLATION="crawl", CHROME_HEADLESS="true", CHROME_SANDBOX="false") + _install_real_chrome_for_test(initialized_archive, env, isolation="crawl") _cmd_result = run_archivebox_cmd( - ["crawl", "create", "https://example.com"], + ["snapshot", "create", recursive_test_site["root_url"]], cwd=initialized_archive, env=env, timeout=60, ) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout - crawl_records = [json.loads(line) for line in stdout.splitlines() if line.strip().startswith("{")] - crawl_id = next(record["id"] for record in crawl_records if record.get("type") == "Crawl") + records = parse_jsonl_output(stdout) + snapshot_id = next(record["id"] for record in records if record.get("type") == "Snapshot") + with use_archivebox_db(initialized_archive): + browser_state = Snapshot.objects.get(id=snapshot_id).output_dir / "chrome" / "browser.json" - daemon_pid: int | None = None - daemon_child_pid: int | None = None - foreground_pid: int | None = None + run_log = initialized_archive / "run-signal-chrome.log" + run_log_handle = run_log.open("w", encoding="utf-8") run_process = run_archivebox_cmd( - ["run", f"--crawl-id={crawl_id}"], + ["run", f"--snapshot-id={snapshot_id}"], cwd=initialized_archive, env=env, - stdout=subprocess.PIPE, + stdout=run_log_handle, stderr=subprocess.STDOUT, start_new_session=True, wait=False, ) + run_log_handle.close() try: - deadline = time.time() + 20 - while time.time() < deadline: - if (leak_test_dir / "daemon.ready").exists() and (leak_test_dir / "foreground.ready").exists(): - break - if run_process.poll() is not None: - output = run_process.communicate(timeout=1)[0] - raise AssertionError(f"archivebox run exited before hooks were ready:\n{output}") - time.sleep(0.05) - assert (leak_test_dir / "daemon.ready").exists() - assert (leak_test_dir / "foreground.ready").exists() - - daemon_pid = int((leak_test_dir / "daemon.pid").read_text().strip()) - daemon_child_pid = int((leak_test_dir / "daemon-child.pid").read_text().strip()) - foreground_pid = int((leak_test_dir / "foreground.pid").read_text().strip()) - assert pid_is_alive(daemon_pid) - assert pid_is_alive(daemon_child_pid) - assert pid_is_alive(foreground_pid) + wait_for_log(browser_state, '"ready": true', timeout=120) + child_pids = [child.pid for child in psutil.Process(run_process.pid).children(recursive=True)] + assert child_pids + assert all(pid_is_alive(pid) for pid in child_pids) run_process.send_signal(signal.SIGTERM) - output = run_process.communicate(timeout=20)[0] + run_process.wait(timeout=30) + output = run_log.read_text(encoding="utf-8", errors="replace") assert "Runner error" not in output - - wait_for_pid_to_disappear(daemon_pid, timeout=5) - wait_for_pid_to_disappear(daemon_child_pid, timeout=5) - wait_for_pid_to_disappear(foreground_pid, timeout=5) - assert (leak_test_dir / "daemon.cleaned").read_text().strip() == "cleaned" - assert (leak_test_dir / "foreground.cleaned").read_text().strip() == "cleaned" + for pid in child_pids: + wait_for_pid_to_disappear(pid, timeout=15) finally: - if run_process.poll() is None: - try: - os.killpg(run_process.pid, signal.SIGKILL) - except ProcessLookupError: - pass - run_process.communicate(timeout=5) - cleanup_process_group(daemon_pid, daemon_child_pid) - cleanup_process_group(foreground_pid) + cleanup_process_group(run_process.pid) class TestRunWithCrawl: """Tests for `archivebox run` with Crawl input.""" def test_run_with_new_crawl(self, initialized_archive): - """Run creates and processes a new Crawl (no id).""" - crawl_record = create_test_crawl_json() + """Run processes a Crawl emitted by the public create command.""" + create_result = run_archivebox_cmd( + ["crawl", "create", create_test_url()], + cwd=initialized_archive, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert create_result.returncode == 0, create_result.stderr _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(crawl_record), + stdin=create_result.stdout, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -204,12 +140,10 @@ class TestRunWithCrawl: disable_extractors=True, ) stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - crawl = parse_jsonl_output(stdout1)[0] - # Run with the existing crawl _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(crawl), + stdin=stdout1, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -227,12 +161,19 @@ class TestRunWithSnapshot: """Tests for `archivebox run` with Snapshot input.""" def test_run_with_new_snapshot(self, initialized_archive): - """Run creates and processes a new Snapshot (no id, just url).""" - snapshot_record = create_test_snapshot_json() + """Run processes a Snapshot emitted by the public create command.""" + create_result = run_archivebox_cmd( + ["snapshot", "create", create_test_url()], + cwd=initialized_archive, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert create_result.returncode == 0, create_result.stderr _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(snapshot_record), + stdin=create_result.stdout, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -261,12 +202,10 @@ class TestRunWithSnapshot: disable_extractors=True, ) stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - snapshot = parse_jsonl_output(stdout1)[0] - # Run with the existing snapshot _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(snapshot), + stdin=stdout1, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -282,11 +221,9 @@ class TestRunWithSnapshot: def test_run_with_plain_url(self, initialized_archive): """Run accepts plain URL records (no type field).""" url = create_test_url() - url_record = {"url": url} - _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(url_record), + stdin=url + "\n", cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -316,34 +253,64 @@ class TestRunWithArchiveResult: disable_extractors=True, ) stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - snapshot = parse_jsonl_output(stdout1)[0] - _cmd_result = run_archivebox_cmd( ["archiveresult", "create", "--plugin=favicon"], - stdin=json.dumps(snapshot), + stdin=stdout1, cwd=initialized_archive, env=RUN_TEST_ENV, default_cli_env=True, disable_extractors=True, ) stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - ar = next(r for r in parse_jsonl_output(stdout2) if r.get("type") == "ArchiveResult") + assert any(record.get("type") == "ArchiveResult" for record in parse_jsonl_output(stdout2)) - # Update to failed - ar["status"] = "failed" - run_archivebox_cmd( - ["archiveresult", "update", "--status=failed"], - stdin=json.dumps(ar), + initial_run = run_archivebox_cmd( + ["run"], + stdin=stdout2, + cwd=initialized_archive, + timeout=120, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert initial_run.returncode == 0, initial_run.stderr + persisted_result = run_archivebox_cmd( + ["archiveresult", "list", "--plugin=favicon"], cwd=initialized_archive, env=RUN_TEST_ENV, default_cli_env=True, disable_extractors=True, ) + assert persisted_result.returncode == 0, persisted_result.stderr + assert any(record.get("type") == "ArchiveResult" for record in parse_jsonl_output(persisted_result.stdout)) + + # Update to failed + update_result = run_archivebox_cmd( + ["archiveresult", "update", "--status=failed"], + stdin=persisted_result.stdout, + cwd=initialized_archive, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert update_result.returncode == 0, update_result.stderr + failed_result = run_archivebox_cmd( + ["archiveresult", "list", "--plugin=favicon"], + cwd=initialized_archive, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert failed_result.returncode == 0, failed_result.stderr + failed_records = [record for record in parse_jsonl_output(failed_result.stdout) if record.get("type") == "ArchiveResult"] + assert len(failed_records) == 1 + assert failed_records[0]["status"] == "failed" + failed_jsonl = next(line for line in failed_result.stdout.splitlines() if failed_records[0]["id"] in line) + "\n" # Now run should re-queue it _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(ar), + stdin=failed_jsonl, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -417,13 +384,20 @@ class TestRunRecovery: class TestRunPassThrough: """Tests for pass-through behavior in `archivebox run`.""" - def test_run_passes_through_unknown_types(self, initialized_archive): - """Run passes through records with unknown types.""" - unknown_record = {"type": "Unknown", "id": "fake-id", "data": "test"} + def test_run_passes_through_tag_emitted_by_cli(self, initialized_archive): + """Run passes through a real non-runnable Tag record.""" + tag_result = run_archivebox_cmd( + ["tag", "create", "run-input-tag"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert tag_result.returncode == 0, tag_result.stderr + tag_record = parse_jsonl_output(tag_result.stdout)[0] _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(unknown_record), + stdin=tag_result.stdout, cwd=initialized_archive, default_cli_env=True, disable_extractors=True, @@ -432,18 +406,25 @@ class TestRunPassThrough: assert code == 0 records = parse_jsonl_output(stdout) - unknown_records = [r for r in records if r.get("type") == "Unknown"] - assert len(unknown_records) == 1 - assert unknown_records[0]["data"] == "test" + tag_records = [record for record in records if record.get("type") == "Tag"] + assert len(tag_records) == 1 + assert tag_records[0]["id"] == tag_record["id"] def test_run_outputs_all_processed_records(self, initialized_archive): """Run outputs all processed records for chaining.""" url = create_test_url() - crawl_record = create_test_crawl_json(urls=[url]) + create_result = run_archivebox_cmd( + ["crawl", "create", url], + cwd=initialized_archive, + env=RUN_TEST_ENV, + default_cli_env=True, + disable_extractors=True, + ) + assert create_result.returncode == 0, create_result.stderr _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(crawl_record), + stdin=create_result.stdout, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -461,23 +442,34 @@ class TestRunPassThrough: class TestRunMixedInput: """Tests for `archivebox run` with mixed record types.""" - def test_run_handles_mixed_types(self, initialized_archive): - """Run handles mixed Crawl/Snapshot/ArchiveResult input.""" - crawl = create_test_crawl_json() - snapshot = create_test_snapshot_json() - unknown = {"type": "Tag", "id": "fake", "name": "test"} - - stdin = "\n".join( - [ - json.dumps(crawl), - json.dumps(snapshot), - json.dumps(unknown), - ], + def test_run_handles_mixed_records_emitted_by_cli(self, initialized_archive): + """Run handles real Crawl, Snapshot, and Tag records from CLI stages.""" + tag_result = run_archivebox_cmd( + ["tag", "create", "mixed-run-tag"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, ) + assert tag_result.returncode == 0, tag_result.stderr + crawl_result = run_archivebox_cmd( + ["crawl", "create", create_test_url()], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert crawl_result.returncode == 0, crawl_result.stderr + snapshot_result = run_archivebox_cmd( + ["snapshot", "create"], + stdin=crawl_result.stdout, + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert snapshot_result.returncode == 0, snapshot_result.stderr _cmd_result = run_archivebox_cmd( ["run"], - stdin=stdin, + stdin=tag_result.stdout + snapshot_result.stdout, cwd=initialized_archive, timeout=120, env=RUN_TEST_ENV, @@ -489,9 +481,8 @@ class TestRunMixedInput: assert code == 0 records = parse_jsonl_output(stdout) - types = {r.get("type") for r in records} - # Should have processed Crawl and Snapshot, passed through Tag - assert "Crawl" in types or "Snapshot" in types or "Tag" in types + types = {record.get("type") for record in records} + assert {"Crawl", "Snapshot", "Tag"}.issubset(types) class TestRunEmpty: @@ -510,13 +501,19 @@ class TestRunEmpty: assert code == 0 - def test_run_no_records_to_process(self, initialized_archive): - """Run with only pass-through records shows message.""" - unknown = {"type": "Unknown", "id": "fake"} + def test_run_no_runnable_records_to_process(self, initialized_archive): + """Run with only a real non-runnable Tag reports no work.""" + tag_result = run_archivebox_cmd( + ["tag", "create", "non-runnable-tag"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert tag_result.returncode == 0, tag_result.stderr _cmd_result = run_archivebox_cmd( ["run"], - stdin=json.dumps(unknown), + stdin=tag_result.stdout, cwd=initialized_archive, default_cli_env=True, disable_extractors=True, @@ -537,72 +534,69 @@ class TestRunDaemonMode: snapshot_url = None if stdin_kind == "valid-snapshot": snapshot_url = create_test_url() - piped_stdin = json.dumps(create_test_snapshot_json(url=snapshot_url)) + "\n" + snapshot_result = run_archivebox_cmd( + ["snapshot", "create", snapshot_url], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert snapshot_result.returncode == 0, snapshot_result.stderr + piped_stdin = snapshot_result.stdout else: piped_stdin = "{this is not jsonl}\n" - env = cli_env() + env = cli_env(PLUGINS="__archivebox_test_no_plugins__") + queued = run_archivebox_cmd( + ["crawl", "create", create_test_url()], + cwd=initialized_archive, + env=env, + timeout=60, + ) + assert queued.returncode == 0, queued.stderr or queued.stdout + daemon_log = initialized_archive / f"run-daemon-{stdin_kind}.log" + daemon_log_handle = daemon_log.open("w", encoding="utf-8") proc = run_archivebox_cmd( ["run", "--daemon"], cwd=initialized_archive, env=env, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=daemon_log_handle, + stderr=subprocess.STDOUT, start_new_session=True, wait=False, ) + daemon_log_handle.close() assert proc.stdin is not None - assert proc.stdout is not None - assert proc.stderr is not None try: proc.stdin.write(piped_stdin) proc.stdin.close() - deadline = time.monotonic() + 20 - started = False - while time.monotonic() < deadline: - if proc.poll() is not None: - stdout = proc.stdout.read() - stderr = proc.stderr.read() - raise AssertionError( - f"daemon exited before starting runner: code={proc.returncode}\nstdout={stdout}\nstderr={stderr}", - ) - with use_archivebox_db(initialized_archive): - started = Process.objects.filter( - process_type=Process.TypeChoices.ORCHESTRATOR, - status=Process.StatusChoices.RUNNING, - pid=proc.pid, - ).exists() - if started: - break - time.sleep(0.25) - - assert started is True + wait_for_log(daemon_log, "[Crawl#", timeout=30) + with use_archivebox_db(initialized_archive): + started = Process.objects.filter( + process_type=Process.TypeChoices.ORCHESTRATOR, + status=Process.StatusChoices.RUNNING, + pid=proc.pid, + ).exists() + assert started if snapshot_url is not None: with use_archivebox_db(initialized_archive): assert not Snapshot.objects.filter(url=snapshot_url).exists() finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - try: - proc.wait(timeout=15) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + os.kill(proc.pid, signal.SIGTERM) + proc.wait(timeout=15) - stdout = proc.stdout.read() - stderr = proc.stderr.read() - assert proc.returncode == 143, stdout + stderr - assert "No records to process" not in stderr + output = daemon_log.read_text(encoding="utf-8", errors="replace") + assert proc.returncode == 143, output + assert "No records to process" not in output def test_run_daemon_takeover_has_single_active_runner_gate(self, initialized_archive, db): from archivebox.machine.models import Process from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE from archivebox.tests.test_orm_helpers import use_archivebox_db - env = cli_env() + env = cli_env(PLUGINS="__archivebox_test_no_plugins__") def active_runners(): with use_archivebox_db(initialized_archive): @@ -617,67 +611,65 @@ class TestRunDaemonMode: if proc.is_running ] - def wait_for_stable_single_active(*, timeout: float, stable_seconds: float = 1.0, exclude_pid: int | None = None): - deadline = time.monotonic() + timeout - stable_pid = None - stable_since = None - while time.monotonic() < deadline: - active = active_runners() - assert len(active) <= 1 - if len(active) == 1 and active[0].pid != exclude_pid: - pid = active[0].pid - if pid != stable_pid: - stable_pid = pid - stable_since = time.monotonic() - elif stable_since is not None and time.monotonic() - stable_since >= stable_seconds: - return pid - else: - stable_pid = None - stable_since = None - time.sleep(0.25) - return None - - procs = [ - run_archivebox_cmd( + queued = run_archivebox_cmd(["crawl", "create", create_test_url()], cwd=initialized_archive, env=env, timeout=60) + assert queued.returncode == 0, queued.stderr or queued.stdout + procs = [] + logs = [] + for index in range(2): + log_path = initialized_archive / f"run-daemon-takeover-{index}.log" + log_handle = log_path.open("w", encoding="utf-8") + proc = run_archivebox_cmd( ["run", "--daemon"], cwd=initialized_archive, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=log_handle, + stderr=subprocess.STDOUT, start_new_session=True, wait=False, ) - for _ in range(2) - ] + log_handle.close() + procs.append(proc) + logs.append(log_path) + if index == 0: + wait_for_log(log_path, "[Crawl#", timeout=30) try: - active_pid = wait_for_stable_single_active(timeout=30) - assert active_pid is not None + wait_for_log(logs[1], "Stopping older ArchiveBox runner process", timeout=30) + queued = run_archivebox_cmd(["crawl", "create", create_test_url()], cwd=initialized_archive, env=env, timeout=60) + assert queued.returncode == 0, queued.stderr or queued.stdout + wait_for_log(logs[1], "[Crawl#", timeout=30) + active = active_runners() + assert len(active) == 1 + active_pid = active[0].pid + assert active_pid == procs[1].pid - os.killpg(active_pid, signal.SIGKILL) + os.kill(active_pid, signal.SIGTERM) + wait_for_pid_to_disappear(active_pid, timeout=15) + replacement_log = initialized_archive / "run-daemon-takeover-replacement.log" + replacement_log_handle = replacement_log.open("w", encoding="utf-8") replacement = run_archivebox_cmd( ["run", "--daemon"], cwd=initialized_archive, env=env, stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=replacement_log_handle, + stderr=subprocess.STDOUT, start_new_session=True, wait=False, ) + replacement_log_handle.close() procs.append(replacement) - recovered_pid = wait_for_stable_single_active(timeout=30, exclude_pid=active_pid) - assert recovered_pid is not None + queued = run_archivebox_cmd(["crawl", "create", create_test_url()], cwd=initialized_archive, env=env, timeout=60) + assert queued.returncode == 0, queued.stderr or queued.stdout + wait_for_log(replacement_log, "[Crawl#", timeout=30) + recovered = active_runners() + assert len(recovered) == 1 + assert recovered[0].pid == replacement.pid + assert recovered[0].pid != active_pid finally: for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=15) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + cleanup_process_group(proc.pid) + proc.wait(timeout=15) @pytest.mark.django_db @@ -1114,11 +1106,12 @@ class TestRecoverOrchestratorState: sealed_crawl.cancel() paused_child.refresh_from_db() + paused_result.refresh_from_db() sealed_child.refresh_from_db() sealed_started_child.refresh_from_db() - assert paused_child.status == Snapshot.StatusChoices.STARTED - assert paused_child.retry_at is not None - assert paused_child.retry_at <= timezone.now() + assert paused_child.status == Snapshot.StatusChoices.PAUSED + assert paused_child.retry_at == RETRY_AT_MAX + assert paused_result.status == ArchiveResult.StatusChoices.PAUSED assert sealed_child.status == Snapshot.StatusChoices.PAUSED assert sealed_child.retry_at is not None assert sealed_child.retry_at <= timezone.now() @@ -1126,13 +1119,6 @@ class TestRecoverOrchestratorState: assert sealed_started_child.retry_at is not None assert sealed_started_child.retry_at <= timezone.now() - assert run_due_snapshot(paused_child, lock_seconds=60) is True - paused_child.refresh_from_db() - paused_result.refresh_from_db() - assert paused_child.status == Snapshot.StatusChoices.PAUSED - assert paused_child.retry_at == RETRY_AT_MAX - assert paused_result.status == ArchiveResult.StatusChoices.PAUSED - assert run_due_snapshot(sealed_child, lock_seconds=60) is True sealed_child.refresh_from_db() assert sealed_child.status == Snapshot.StatusChoices.SEALED @@ -1243,7 +1229,7 @@ class TestRecoverOrchestratorState: assert crawl.retry_at < future assert snapshot.retry_at < future - def test_recover_orchestrator_state_preserves_future_started_snapshot_with_live_result_process(self): + def test_recover_orchestrator_state_preserves_future_started_snapshot_with_live_result_process(self, initialized_archive): from datetime import timedelta from django.utils import timezone @@ -1254,13 +1240,19 @@ class TestRecoverOrchestratorState: from archivebox.machine.models import Machine, NetworkInterface, Process from archivebox.core.recovery_util import recover_orchestrator_state - worker = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], + worker = run_archivebox_cmd( + ["manage", "shell"], + cwd=initialized_archive, + env=cli_env(live=True), + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - text=True, + capture_output=False, start_new_session=True, + wait=False, ) + assert worker.stdin is not None + assert pid_is_alive(worker.pid) try: future = timezone.now() + timedelta(seconds=45) crawl = Crawl.objects.create( @@ -1303,13 +1295,9 @@ class TestRecoverOrchestratorState: assert snapshot.status == Snapshot.StatusChoices.STARTED assert snapshot.retry_at == future finally: - if worker.poll() is None: - os.killpg(worker.pid, signal.SIGTERM) - try: - worker.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(worker.pid, signal.SIGKILL) - worker.wait(timeout=5) + worker.stdin.close() + worker.wait(timeout=20) + assert not pid_is_alive(worker.pid) def test_recover_orchestrator_state_does_not_resume_paused_rows_with_max_retry_at(self): from archivebox.base_models.models import get_or_create_system_user_pk @@ -1628,7 +1616,7 @@ class TestRecoverOrchestratorState: update_process = run_archivebox_cmd( ["archiveresult", "update", "--status=queued"], - stdin=json.dumps(wait_record) + "\n", + stdin=next(line for line in list_process.stdout.splitlines() if wait_record["id"] in line) + "\n", cwd=initialized_archive, env=env, timeout=60, @@ -1649,47 +1637,27 @@ class TestRecoverOrchestratorState: run_process.stdin.write(update_process.stdout) run_process.stdin.close() - resumed_full_plugin = False try: - deadline = time.time() + 90 - last_wait_status = None - last_tab_process_id = None - while time.time() < deadline: - with use_archivebox_db(initialized_archive): - wait_result = ArchiveResult.objects.get( - snapshot_id=snapshot_id, - plugin="chrome", - hook_name="on_Snapshot__11_chrome_wait", - ) - tab_result = ArchiveResult.objects.get( - snapshot_id=snapshot_id, - plugin="chrome", - hook_name="on_Snapshot__10_chrome_tab.daemon.bg", - ) - last_wait_status = wait_result.status - last_tab_process_id = tab_result.process_id - if wait_result.status == ArchiveResult.StatusChoices.SUCCEEDED and tab_result.process_id != first_tab_process_id: - resumed_full_plugin = True - break - if run_process.poll() is not None: - break - time.sleep(0.5) - - if resumed_full_plugin: - try: - run_process.wait(timeout=30) - except subprocess.TimeoutExpired: - cleanup_process_group(run_process.pid) - run_process.wait(timeout=10) + run_process.wait(timeout=120) finally: - if run_process.poll() is None: - cleanup_process_group(run_process.pid) - run_process.wait(timeout=10) + cleanup_process_group(run_process.pid) + + with use_archivebox_db(initialized_archive): + wait_result = ArchiveResult.objects.get( + snapshot_id=snapshot_id, + plugin="chrome", + hook_name="on_Snapshot__11_chrome_wait", + ) + tab_result = ArchiveResult.objects.get( + snapshot_id=snapshot_id, + plugin="chrome", + hook_name="on_Snapshot__10_chrome_tab.daemon.bg", + ) assert run_process.returncode == 0 - assert last_wait_status == ArchiveResult.StatusChoices.SUCCEEDED - assert last_tab_process_id is not None - assert last_tab_process_id != first_tab_process_id + assert wait_result.status == ArchiveResult.StatusChoices.SUCCEEDED + assert tab_result.process_id is not None + assert tab_result.process_id != first_tab_process_id def test_recover_orchestrator_state_ignores_sealed_downloaded_snapshot_without_results(self): from django.utils import timezone @@ -1912,13 +1880,15 @@ class TestRunDueCrawlState: assert finished.output_files == {"favicon.ico": {"size": 1}} def test_finished_parser_result_projects_children_before_resume_seals_snapshot(self): - import json + from importlib.resources import files + from pathlib import Path from django.utils import timezone from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import ArchiveResult, Snapshot + from archivebox.plugins.hooks import extract_records_from_process, run_hook from archivebox.services.runner import run_due_snapshot crawl = Crawl.objects.create( @@ -1935,17 +1905,34 @@ class TestRunDueCrawlState: status=Snapshot.StatusChoices.STARTED, retry_at=timezone.now(), ) + staticfile_dir = root.output_dir / "staticfile" parser_dir = root.output_dir / "parse_txt_urls" + staticfile_dir.mkdir(parents=True, exist_ok=True) parser_dir.mkdir(parents=True, exist_ok=True) - (parser_dir / "urls.jsonl").write_text( - json.dumps({"type": "Snapshot", "url": "https://example.org/"}) + "\n", + (staticfile_dir / "input.txt").write_text( + "Plain text import containing https://example.org/\n", encoding="utf-8", ) + hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py"))) + process = run_hook( + hook_path, + parser_dir, + config={"ABXPKG_LIB_DIR": str(root.output_dir.parent.parent / "lib"), "SNAP_DIR": str(root.output_dir)}, + timeout=30, + url=root.url, + depth=root.depth, + snapshot_id=str(root.id), + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + result_record = next(record for record in extract_records_from_process(process) if record.get("type") == "ArchiveResult") ArchiveResult.objects.create( snapshot=root, - plugin="parse_txt_urls", - hook_name="on_Snapshot__71_parse_txt_urls", - status=ArchiveResult.StatusChoices.SUCCEEDED, + process=process, + plugin=result_record["plugin"], + hook_name=result_record["hook_name"], + status=result_record["status"], + output_str=result_record.get("output_str", ""), output_files={"urls.jsonl": {"size": (parser_dir / "urls.jsonl").stat().st_size}}, ) diff --git a/archivebox/tests/test_cli_schedule.py b/archivebox/tests/test_cli_schedule.py index 81bae29e..ff970f35 100644 --- a/archivebox/tests/test_cli_schedule.py +++ b/archivebox/tests/test_cli_schedule.py @@ -15,7 +15,7 @@ from .conftest import ( make_latest_schedule_due, start_archivebox_server, stop_server, - wait_for_http, + get_http_response, wait_for_snapshot_capture, ) @@ -162,7 +162,7 @@ def test_schedule_due_crawl_runs_over_server_and_saves_real_content(tmp_path, re try: start_archivebox_server(tmp_path, env=env, port=port) - wait_for_http(port, host=f"web.archivebox.localhost:{port}") + get_http_response(port, host=f"web.archivebox.localhost:{port}") captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180) assert "Root" in captured_text assert "About" in captured_text diff --git a/archivebox/tests/test_cli_search.py b/archivebox/tests/test_cli_search.py index f3780796..9dc1a127 100644 --- a/archivebox/tests/test_cli_search.py +++ b/archivebox/tests/test_cli_search.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox search command. - -TODO: keep search-specific tests here instead of folding all coverage into test_cli_list.py. """ import json @@ -62,12 +60,3 @@ def test_cli_search_status_filters_snapshot_status_column(tmp_path, initialized_ rows = [json.loads(line) for line in result.stdout.splitlines() if line.strip().startswith("{")] assert [row["status"] for row in rows] == ["sealed"] assert [row["url"] for row in rows] == ["https://example.com/search-status-sealed"] - - legacy_result = run_archivebox_cmd( - ["search", "--status", "unarchived", "search-status"], - env=env, - timeout=30, - ) - - assert legacy_result.returncode != 0 - assert "Invalid snapshot status" in legacy_result.stderr diff --git a/archivebox/tests/test_cli_server.py b/archivebox/tests/test_cli_server.py index 56c4e136..c6be16f0 100644 --- a/archivebox/tests/test_cli_server.py +++ b/archivebox/tests/test_cli_server.py @@ -8,10 +8,10 @@ import os import asyncio import json import signal +import shlex import socket import subprocess import sys -import time from datetime import datetime from pathlib import Path from types import SimpleNamespace @@ -19,15 +19,18 @@ from types import SimpleNamespace import pytest from archivebox.tests.conftest import ( + _wait_for_archivebox_workers, assert_no_processes_for_data_dir, + assert_port_open, + find_process, get_free_port, kill_processes_for_data_dir, cli_env, + pid_is_alive, start_archivebox_server, stop_archivebox_process, + wait_for_log_count, wait_for_pid_to_disappear, - wait_for_port_open, - wait_for_process, run_archivebox_cmd, resolve_abxpkg_binary_env, ) @@ -42,12 +45,13 @@ def _resolve_sonic_env(data_dir: Path) -> dict[str, str]: return resolved -def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, monkeypatch): +def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path): """Admin sessions must survive `archivebox server` restarts for a collection.""" from archivebox.config.collection import write_config_file (tmp_path / ".archivebox_id").write_text("testcoll") - monkeypatch.setenv("BASE_URL", "http://archivebox.localhost:9292") + first_env = os.environ.copy() + first_env["BASE_URL"] = "http://archivebox.localhost:9292" first = subprocess.run( [ @@ -70,6 +74,8 @@ def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, mon capture_output=True, text=True, check=True, + cwd=tmp_path, + env=first_env, ) first_lines = first.stdout.strip().splitlines() assert first_lines[0], first.stderr @@ -77,8 +83,9 @@ def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, mon # Simulate the next `archivebox server` process, reading only persisted # collection config. If SECRET_KEY falls back to the random default_factory # here, Django will reject existing signed session cookies after restart. - monkeypatch.delenv("BASE_URL", raising=False) write_config_file({"BASE_URL": "http://archivebox.localhost:9292"}) + second_env = os.environ.copy() + second_env.pop("BASE_URL", None) second = subprocess.run( [ sys.executable, @@ -100,6 +107,8 @@ def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, mon capture_output=True, text=True, check=True, + cwd=tmp_path, + env=second_env, ) assert second.stdout.strip().splitlines() == first_lines @@ -181,13 +190,13 @@ def test_server_help_lists_runtime_options(initialized_archive): assert "--reload" in result.stdout -def test_runner_worker_uses_current_interpreter(): - """The supervised runner should use the active Python environment, not PATH.""" - from archivebox.workers.supervisord_util import RUNNER_WORKER +def test_runner_worker_uses_abxpkg_projected_archivebox(): + from archivebox.workers.supervisord_util import RUNNER_WORKER, resolve_env_binary - assert RUNNER_WORKER["command"] == f"{sys.executable} -m archivebox run --daemon" - assert RUNNER_WORKER["autorestart"] == "true" - assert 'ARCHIVEBOX_RUNNER_DAEMON="1"' in RUNNER_WORKER["environment"] + worker = RUNNER_WORKER() + assert shlex.split(worker["command"]) == [str(resolve_env_binary("archivebox")), "run", "--daemon"] + assert worker["autorestart"] == "true" + assert 'ARCHIVEBOX_RUNNER_DAEMON="1"' in worker["environment"] def test_daphne_worker_uses_default_application_close_timeout(): @@ -199,20 +208,26 @@ def test_daphne_worker_uses_default_application_close_timeout(): assert "--application-close-timeout=0" not in command -def test_reload_workers_use_current_interpreter_and_supervisord_managed_runner(): - from archivebox.workers.supervisord_util import RUNNER_WATCH_WORKER, RUNSERVER_WORKER +def test_reload_workers_use_abxpkg_projected_archivebox(): + from archivebox.workers.supervisord_util import RUNNER_WATCH_WORKER, RUNSERVER_WORKER, resolve_env_binary runserver = RUNSERVER_WORKER("127.0.0.1", "8000", reload=True) watcher = RUNNER_WATCH_WORKER("http://127.0.0.1:8000") + archivebox_binary = resolve_env_binary("archivebox") assert runserver["name"] == "worker_runserver" - assert runserver["command"] == f"{sys.executable} -m archivebox manage runserver 127.0.0.1:8000" + assert shlex.split(runserver["command"]) == [str(archivebox_binary), "manage", "runserver", "127.0.0.1:8000"] assert 'ARCHIVEBOX_RUNSERVER="1"' in runserver["environment"] assert 'ARCHIVEBOX_AUTORELOAD="1"' in runserver["environment"] assert 'ARCHIVEBOX_RUNSERVER_BIND_URL="http://127.0.0.1:8000"' in runserver["environment"] assert watcher["name"] == "worker_runner_watch" - assert watcher["command"] == f"{sys.executable} -m archivebox manage runner_watch --bind-url=http://127.0.0.1:8000" + assert shlex.split(watcher["command"]) == [ + str(archivebox_binary), + "manage", + "runner_watch", + "--bind-url=http://127.0.0.1:8000", + ] def test_server_daemon_starts_real_plugin_owned_sonic_worker(initialized_archive, archivebox_daemon_server): @@ -235,18 +250,17 @@ def test_server_daemon_restarts_runner_killed_by_signal(archivebox_daemon_server ) state = server.wait_for_workers(("worker_daphne", "worker_runner")) old_runner_pid = state["worker_runner"]["pid"] + supervisord_log = server.data_dir / "logs" / "supervisord.log" + spawn_text = "spawned: 'worker_runner' with pid" + spawn_count = supervisord_log.read_text(encoding="utf-8", errors="replace").count(spawn_text) os.kill(old_runner_pid, signal.SIGTERM) - deadline = time.time() + 30 - while time.time() < deadline: - state = server.worker_state() - runner = state.get("worker_runner", {}) - if runner.get("statename") == "RUNNING" and runner.get("pid") and runner.get("pid") != old_runner_pid: - break - time.sleep(0.5) - else: - raise AssertionError(f"worker_runner did not restart after SIGTERM: {state}") + wait_for_log_count(supervisord_log, spawn_text, spawn_count + 1, timeout=30) + state = server.worker_state() + runner = state["worker_runner"] + assert runner["statename"] == "RUNNING", state + assert runner["pid"] != old_runner_pid, state assert state["worker_daphne"]["statename"] == "RUNNING", state @@ -472,8 +486,7 @@ def test_supervisord_takeover_stops_all_live_process_rows(initialized_archive, d ).exists() finally: for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGKILL) + stop_archivebox_process(proc, signal.SIGTERM) @pytest.mark.timeout(300) @@ -497,11 +510,7 @@ def test_live_server_signal_exit_and_resume_uses_existing_supervisor_state(initi server_log = server.log_path os.kill(server.pid, stop_signal) - try: - server.wait(timeout=20 if stop_signal != signal.SIGKILL else 5) - except subprocess.TimeoutExpired: - os.kill(server.pid, signal.SIGKILL) - server.wait(timeout=5) + server.wait(timeout=20) if expected_notice: log_text = server_log.read_text(encoding="utf-8", errors="replace") @@ -523,8 +532,8 @@ def test_live_server_signal_exit_and_resume_uses_existing_supervisor_state(initi assert_no_processes_for_data_dir(initialized_archive, timeout=12) finally: for proc in (server, resumed): - if proc is not None and proc.poll() is None: - stop_archivebox_process(proc, signal.SIGKILL) + if proc is not None: + stop_archivebox_process(proc, signal.SIGTERM) kill_processes_for_data_dir(initialized_archive) @@ -543,19 +552,20 @@ def test_live_daemonized_server_keeps_supervisord_owned_by_archivebox_parent(ini ) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout - wait_for_port_open("127.0.0.1", port, timeout=30) + _wait_for_archivebox_workers(initialized_archive, env, ("worker_daphne", "worker_runner"), timeout=30) + assert_port_open("127.0.0.1", port, timeout=30) - server_process = wait_for_process( + server_process = find_process( lambda _proc, command: "archivebox" in command and " server " in f" {command} " and bind_url.replace("http://", "") in command, ) - supervisord = wait_for_process( + supervisord = find_process( lambda proc, command: proc.ppid() == server_process.pid and "supervisord" in command, ) - wait_for_process( + find_process( lambda proc, command: proc.ppid() == supervisord.pid and "supervisord_watchdog" in command, ) - os.kill(server_process.pid, signal.SIGKILL) + os.kill(server_process.pid, signal.SIGTERM) wait_for_pid_to_disappear(server_process.pid, timeout=10) wait_for_pid_to_disappear(supervisord.pid, timeout=20) assert_no_processes_for_data_dir(initialized_archive, timeout=12) @@ -608,7 +618,7 @@ def test_live_servers_in_different_data_dirs_do_not_interfere(initialized_archiv stop_archivebox_process(first, signal.SIGTERM) first = None - assert second.poll() is None, "stopping one DATA_DIR server must not stop another DATA_DIR server" + assert pid_is_alive(second.pid), "stopping one DATA_DIR server must not stop another DATA_DIR server" first_resumed = start_archivebox_server( first_data_dir, @@ -616,10 +626,10 @@ def test_live_servers_in_different_data_dirs_do_not_interfere(initialized_archiv log_name="server-first-data-dir-resumed.log", env=cli_env(live=True), ) - assert second.poll() is None, "restarting one DATA_DIR server must not take over another DATA_DIR supervisor" + assert pid_is_alive(second.pid), "restarting one DATA_DIR server must not take over another DATA_DIR supervisor" finally: for proc in (first, first_resumed, second): - if proc is not None and proc.poll() is None: + if proc is not None: stop_archivebox_process(proc, signal.SIGTERM) kill_processes_for_data_dir(first_data_dir) kill_processes_for_data_dir(second_data_dir) diff --git a/archivebox/tests/test_cli_snapshot.py b/archivebox/tests/test_cli_snapshot.py index 21a12078..b3e1ed90 100644 --- a/archivebox/tests/test_cli_snapshot.py +++ b/archivebox/tests/test_cli_snapshot.py @@ -95,11 +95,18 @@ class TestSnapshotCreate: records = parse_jsonl_output(stdout) assert "test-tag" in records[0].get("tags", "") - def test_create_pass_through_other_types(self, initialized_archive): - """Pass-through records of other types unchanged.""" - tag_record = {"type": "Tag", "id": "fake-tag-id", "name": "test"} + def test_create_passes_through_tag_emitted_by_cli(self, initialized_archive): + """A real Tag emitted by the CLI remains available to the next stage.""" + tag_result = run_archivebox_cmd( + ["tag", "create", "snapshot-input-tag"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, + ) + assert tag_result.returncode == 0, tag_result.stderr + tag_record = parse_jsonl_output(tag_result.stdout)[0] url = create_test_url() - stdin = json.dumps(tag_record) + "\n" + json.dumps({"url": url}) + stdin = tag_result.stdout + url + "\n" _cmd_result = run_archivebox_cmd( ["snapshot", "create"], @@ -113,9 +120,8 @@ class TestSnapshotCreate: assert code == 0 records = parse_jsonl_output(stdout) - types = [r.get("type") for r in records] - assert "Tag" in types - assert "Snapshot" in types + assert any(record.get("type") == "Tag" and record["id"] == tag_record["id"] for record in records) + assert any(record.get("type") == "Snapshot" and record["url"] == url for record in records) def test_create_multiple_urls(self, initialized_archive): """Create snapshots from multiple URLs.""" diff --git a/archivebox/tests/test_cli_tag.py b/archivebox/tests/test_cli_tag.py index 2af1f78d..1c0cc009 100644 --- a/archivebox/tests/test_cli_tag.py +++ b/archivebox/tests/test_cli_tag.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 """ Tests for archivebox tag command. - -TODO: expand beyond command discovery into create/list/update/delete behavior. """ from archivebox.tests.conftest import run_archivebox_cmd diff --git a/archivebox/tests/test_config_DELETE_AFTER.py b/archivebox/tests/test_config_DELETE_AFTER.py index 9886c47c..cdca03a0 100644 --- a/archivebox/tests/test_config_DELETE_AFTER.py +++ b/archivebox/tests/test_config_DELETE_AFTER.py @@ -14,7 +14,7 @@ ADMIN_HOST = "admin.archivebox.localhost:8000" API_HOST = "api.archivebox.localhost:8000" -def test_delete_after_real_cli_and_orchestrator_paths_cover_all_retained_models(tmp_path): +def test_delete_after_real_cli_and_orchestrator_paths_cover_all_retained_models(tmp_path, recursive_test_site): env = cli_env(disable_extractors=True) _cmd_result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, timeout=90) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode @@ -22,13 +22,14 @@ def test_delete_after_real_cli_and_orchestrator_paths_cover_all_retained_models( run_env = { **env, + "ABXPKG_LIB_DIR": str(tmp_path / "lib"), "DELETE_AFTER": "1hr", "USE_COLOR": "False", "SHOW_PROGRESS": "False", } - url = "https://example.com/delete-after-cli" + url = recursive_test_site["root_url"] _cmd_result = run_archivebox_cmd( - ["add", "--index-only", "--depth=0", url], + ["add", "--plugins=wget,hashes", "--depth=0", url], cwd=tmp_path, timeout=120, env=run_env, @@ -62,69 +63,39 @@ print(json.dumps({{ setup_script = f""" import json +import os from pathlib import Path from datetime import timedelta from django.utils import timezone from archivebox.core.models import ArchiveResult, Snapshot -from archivebox.machine.models import Machine, NetworkInterface, Process +from archivebox.machine.models import Process snapshot = Snapshot.objects.select_related("crawl").get(id="{created["snapshot_id"]}") crawl = snapshot.crawl -if snapshot.status == snapshot.StatusChoices.QUEUED: - snapshot.sm.tick() - snapshot.refresh_from_db() -if snapshot.status == snapshot.StatusChoices.STARTED: - snapshot.sm.seal() - snapshot.refresh_from_db() -crawl.refresh_from_db() -if crawl.status == crawl.StatusChoices.QUEUED: - crawl.sm.tick() - crawl.refresh_from_db() -if crawl.status == crawl.StatusChoices.STARTED: - crawl.sm.seal() - crawl.refresh_from_db() if snapshot.status != snapshot.StatusChoices.SEALED or crawl.status != crawl.StatusChoices.SEALED: raise RuntimeError(f"expected sealed snapshot/crawl, got {{snapshot.status}}/{{crawl.status}}") -Path(crawl.output_dir).mkdir(parents=True, exist_ok=True) -Path(snapshot.output_dir).mkdir(parents=True, exist_ok=True) -(Path(crawl.output_dir) / "crawl-retention.txt").write_text("crawl") -(Path(snapshot.output_dir) / "snapshot-retention.txt").write_text("snapshot") - -result = ArchiveResult.objects.create( +result = ArchiveResult.objects.select_related("process").get( snapshot=snapshot, - plugin="title", - hook_name="on_Snapshot__54_title.py", - status=ArchiveResult.StatusChoices.SUCCEEDED, + plugin="hashes", + hook_name="on_Snapshot__93_hashes", ) -Path(result.output_dir).mkdir(parents=True, exist_ok=True) -(Path(result.output_dir) / "title.txt").write_text("Example") - -machine = Machine.current() -iface = NetworkInterface.objects.filter(machine=machine).first() -if iface is None: - iface = NetworkInterface.objects.create( - machine=machine, - mac_address="00:00:00:00:00:00", - ip_public="203.0.113.10", - ip_local="127.0.0.1", - dns_server="1.1.1.1", - hostname=machine.hostname, - iface="lo", - isp="Test ISP", - city="Test City", - region="Test Region", - country="Test Country", - ) -process = Process.objects.create( - machine=machine, - iface=iface, - process_type=Process.TypeChoices.HOOK, - pwd=str(result.output_dir), - cmd=["echo", "ok"], - env={{"DELETE_AFTER": "1hr"}}, - status=Process.StatusChoices.EXITED, +if result.status != ArchiveResult.StatusChoices.SUCCEEDED or result.process_id is None: + raise RuntimeError(f"expected successful projected hashes result, got {{result.status}}/{{result.process_id}}") +process = result.process +if process.status != Process.StatusChoices.EXITED or process.exit_code != 0: + raise RuntimeError(f"expected successful real hook process, got {{process.status}}/{{process.exit_code}}") +if not Path(result.output_dir).is_dir(): + raise RuntimeError(f"expected real hook output directory at {{result.output_dir}}") +runner = next( + (row for row in Process.objects.filter(worker_type="worker_runner").order_by("created_at") if "--no-stdin" in row.cmd), + None, ) +if runner is None: + raise RuntimeError("expected persisted worker_runner process") +archivebox_projection = Path(os.environ["ABXPKG_LIB_DIR"]) / "env" / "bin" / "archivebox" +if not archivebox_projection.is_symlink() or str(archivebox_projection) not in runner.cmd or "-m" in runner.cmd: + raise RuntimeError(f"worker did not execute projected archivebox: {{runner.cmd}}") due_at = timezone.now() - timedelta(hours=1) ArchiveResult.objects.filter(pk=result.pk).update(delete_at=due_at) @@ -137,7 +108,8 @@ print(json.dumps({{ "crawl_id": str(crawl.id), "snapshot_id": str(snapshot.id), "archiveresult_id": str(result.id), - "process_id": str(process.id), + "process_id": str(process.id), + "runner_cmd": runner.cmd, "crawl_dir": str(crawl.output_dir), "snapshot_dir": str(snapshot.output_dir), "archiveresult_dir": str(result.output_dir), diff --git a/archivebox/tests/test_config_MAX_limits.py b/archivebox/tests/test_config_MAX_limits.py index 415be2ed..3fba65fc 100644 --- a/archivebox/tests/test_config_MAX_limits.py +++ b/archivebox/tests/test_config_MAX_limits.py @@ -2,6 +2,7 @@ import asyncio import json +from importlib.resources import files from pathlib import Path import pytest @@ -66,6 +67,7 @@ def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl from archivebox.core.models import Snapshot + from archivebox.plugins.hooks import run_hook from archivebox.services.runner import CrawlRunner crawl = Crawl.objects.create( @@ -80,17 +82,26 @@ def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path): status=Snapshot.StatusChoices.SEALED, depth=0, ) - parser_dir = Path(snapshot.output_dir) / "parse_html_urls" + snap_dir = Path(snapshot.output_dir) + staticfile_dir = snap_dir / "staticfile" + parser_dir = snap_dir / "parse_txt_urls" + staticfile_dir.mkdir(parents=True, exist_ok=True) parser_dir.mkdir(parents=True, exist_ok=True) - (parser_dir / "urls.jsonl").write_text( - "\n".join( - [ - json.dumps({"type": "Snapshot", "url": "https://example.com/child-a", "depth": 1}), - json.dumps({"type": "Snapshot", "url": "https://example.com/child-b", "depth": 1}), - "", - ], - ), + (staticfile_dir / "input.txt").write_text( + "https://example.com/child-a\nhttps://example.com/child-b\n", + encoding="utf-8", ) + hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py"))) + process = run_hook( + hook_path, + parser_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url=snapshot.url, + depth=snapshot.depth, + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr runner = CrawlRunner(crawl) Crawl.objects.filter(id=crawl.id).update(max_depth=1) @@ -220,7 +231,7 @@ def test_seal_snapshot_cancels_queued_descendants_after_crawl_max_size(): ) bus = create_bus(name=f"test_snapshot_limit_cancel_{str(crawl.id).replace('-', '_')}") - service = SnapshotService(bus, crawl_id=str(crawl.id), schedule_snapshot=lambda snapshot_id: None) + service = SnapshotService(bus, crawl_id=str(crawl.id)) try: async def emit_event() -> None: diff --git a/archivebox/tests/test_config_SAVE_TITLE.py b/archivebox/tests/test_config_SAVE_TITLE.py index 40f527c5..4092e689 100644 --- a/archivebox/tests/test_config_SAVE_TITLE.py +++ b/archivebox/tests/test_config_SAVE_TITLE.py @@ -1,7 +1,6 @@ -import time from pathlib import Path -from archivebox.tests.conftest import run_archivebox_cmd, cli_env +from archivebox.tests.conftest import cli_env, run_archivebox_cmd, run_queued_crawls import pytest @@ -25,18 +24,6 @@ def _install_chrome(tmp_path, env): env.update(resolve_abxpkg_chrome_env(Path(env["ABXPKG_LIB_DIR"]), env)) -def _wait_for_snapshot_title(data_dir, *, timeout=60): - deadline = time.time() + timeout - title = None - while time.time() < deadline: - with use_archivebox_db(data_dir): - title = Snapshot.objects.get().resolved_title - if title: - return title - time.sleep(0.5) - return title - - def test_title_is_extracted(tmp_path, initialized_archive, recursive_test_site): """Test that title is extracted from the page.""" env = cli_env(disable_extractors=True) @@ -48,8 +35,10 @@ def test_title_is_extracted(tmp_path, initialized_archive, recursive_test_site): env=env, ) assert add_process.returncode == 0, add_process.stderr or add_process.stdout + run_queued_crawls(tmp_path, env=env) - title = _wait_for_snapshot_title(tmp_path) + with use_archivebox_db(tmp_path): + title = Snapshot.objects.get().resolved_title assert title is not None assert "Root" in title diff --git a/archivebox/tests/test_crawl_runner.py b/archivebox/tests/test_crawl_runner.py index 17ed484a..0376766a 100644 --- a/archivebox/tests/test_crawl_runner.py +++ b/archivebox/tests/test_crawl_runner.py @@ -1,6 +1,7 @@ import asyncio -import sys +from importlib.resources import files from pathlib import Path +import sys import pytest from asgiref.sync import sync_to_async @@ -33,15 +34,13 @@ def test_cancelled_crawl_projection_emits_abort_event_from_runner_bus(): abort_event_holder: dict[str, CrawlAbortEvent | None] = {"event": None} async def on_CrawlEvent(event: CrawlEvent) -> None: - watcher = asyncio.create_task(runner.watch_for_cancelled_crawl(event, poll_interval=0.01)) - await asyncio.sleep(0.02) await sync_to_async(Crawl.objects.filter(id=crawl.id).update, thread_sensitive=True)( status=Crawl.StatusChoices.SEALED, retry_at=None, ) + await runner.watch_for_cancelled_crawl(event, poll_interval=0) abort_event = await runner.bus.find(CrawlAbortEvent, child_of=event, past=True, future=1.0) abort_event_holder["event"] = abort_event if isinstance(abort_event, CrawlAbortEvent) else None - await watcher runner.bus.on(CrawlEvent, on_CrawlEvent) await runner.bus.emit( @@ -59,7 +58,6 @@ def test_cancelled_crawl_projection_emits_abort_event_from_runner_bus(): assert abort_event is not None -@pytest.mark.django_db(transaction=True) @pytest.mark.django_db(transaction=True) def test_snapshot_payload_uses_crawl_chrome_dirs_by_default(): from archivebox.base_models.models import get_or_create_system_user_pk @@ -145,22 +143,19 @@ def test_snapshot_payload_uses_snapshot_chrome_dirs_when_snapshot_isolated(): assert Path(config["SNAP_DIR"]) == snapshot.output_dir -def test_ensure_background_runner_skips_under_pytest_guard(): - from archivebox.services.runner import ensure_background_runner - - assert ensure_background_runner() is False - - @pytest.mark.django_db(transaction=True) -def test_ensure_background_runner_skips_with_real_running_orchestrator_record(): +def test_ensure_background_runner_does_not_start_duplicate_orchestrator(): import os from datetime import datetime import psutil from archivebox.machine.models import Machine, Process from archivebox.services.runner import ensure_background_runner + from archivebox.workers.supervisord_util import get_existing_supervisord_process, stop_existing_supervisord_process from django.utils import timezone + stop_existing_supervisord_process() + assert get_existing_supervisord_process(quiet=True) is None os_proc = psutil.Process(os.getpid()) process = Process.objects.create( machine=Machine.current(), @@ -171,7 +166,7 @@ def test_ensure_background_runner_skips_with_real_running_orchestrator_record(): timeout=1, ) - assert ensure_background_runner(allow_under_pytest=True) is False + assert ensure_background_runner() is False process.refresh_from_db() assert process.status == Process.StatusChoices.RUNNING @@ -184,7 +179,7 @@ def test_ensure_background_runner_does_not_spawn_runner_without_supervisord(): stop_existing_supervisord_process() assert get_existing_supervisord_process(quiet=True) is None - assert ensure_background_runner(allow_under_pytest=True) is False + assert ensure_background_runner() is False assert get_existing_supervisord_process(quiet=True) is None @@ -783,11 +778,9 @@ def test_wait_for_snapshot_tasks_returns_after_completed_tasks_are_pruned(): ) crawl_runner = runner_module.CrawlRunner(crawl) - async def finish_snapshot() -> None: - await asyncio.sleep(0) - async def run_test(): - task = asyncio.create_task(finish_snapshot()) + task = asyncio.get_running_loop().create_future() + task.set_result(None) crawl_runner.snapshot_tasks["snap-1"] = task await asyncio.wait_for(crawl_runner.wait_for_snapshot_tasks(), timeout=0.5) assert crawl_runner.snapshot_tasks == {} @@ -795,10 +788,11 @@ def test_wait_for_snapshot_tasks_returns_after_completed_tasks_are_pruned(): asyncio.run(run_test()) -def test_abx_process_service_background_process_finishes_after_process_exit(tmp_path): +def test_abx_process_service_background_process_finishes_after_process_exit(tmp_path, recursive_test_site, hermetic_lib_dir): from abx_dl.events import ProcessCompletedEvent, ProcessEvent from abx_dl.orchestrator import create_bus from abx_dl.services.process_service import ProcessService + from archivebox.machine.models import Process bus = create_bus(name="test_abx_process_service_background_process_finishes_after_process_exit") ProcessService(bus, emit_jsonl=False, interactive_tty=False) @@ -809,21 +803,29 @@ def test_abx_process_service_background_process_finishes_after_process_exit(tmp_ bus.on(ProcessCompletedEvent, collect_completed) - plugin_output_dir = tmp_path / "chrome" - plugin_output_dir.mkdir() + snap_dir = tmp_path / "snapshot" + plugin_output_dir = snap_dir / "wget" + plugin_output_dir.mkdir(parents=True) + hook_path = Path(str(files("abx_plugins.plugins.wget").joinpath("on_Snapshot__06_wget.finite.bg.py"))) + wget_config = Path(str(files("abx_plugins.plugins.wget").joinpath("config.json"))) + hook_env = resolve_abxpkg_binary_env(hermetic_lib_dir, deps_from=wget_config) async def run_test(): try: event = ProcessEvent( - plugin_name="chrome", - hook_name="on_CrawlSetup__90_chrome_launch.daemon.bg", - hook_path=sys.executable, - hook_args=["-c", "print('daemon output')"], - env={}, + plugin_name="wget", + hook_name=hook_path.name, + hook_path=str(hook_path), + hook_args=[f"--url={recursive_test_site['root_url']}"], + env={ + **hook_env, + "ABXPKG_LIB_DIR": str(hermetic_lib_dir), + "SNAP_DIR": str(snap_dir), + }, output_dir=str(plugin_output_dir), timeout=60, is_background=True, - url="https://example.org/", + url=recursive_test_site["root_url"], process_type="hook", worker_type="hook", ) @@ -832,7 +834,14 @@ def test_abx_process_service_background_process_finishes_after_process_exit(tmp_ assert isinstance(completed, ProcessCompletedEvent) await completed.event_results_list() assert completed.status == "succeeded" - assert completed.stdout.strip() == "daemon output" + records = [record for record in Process.parse_records_from_text(completed.stdout) if record.get("type") == "ArchiveResult"] + assert records == [ + { + "type": "ArchiveResult", + "status": "succeeded", + "output_str": f"wget/127.0.0.1+{recursive_test_site['base_url'].rsplit(':', 1)[-1]}/index.html", + }, + ] assert completed.output_dir == str(plugin_output_dir) assert bus.event_is_child_of(completed, event) finally: @@ -840,7 +849,8 @@ def test_abx_process_service_background_process_finishes_after_process_exit(tmp_ asyncio.run(run_test()) - assert not list(plugin_output_dir.glob("on_CrawlSetup__90_chrome_launch.daemon.bg.*.pid")) + assert list(plugin_output_dir.rglob("index.html")) + assert not list(plugin_output_dir.glob(f"{hook_path.name}.*.pid")) assert any(isinstance(event, ProcessCompletedEvent) for event in emitted_events) @@ -1076,7 +1086,7 @@ def test_snapshot_completed_event_defers_finished_crawl_seal(): ) bus = create_bus(name=f"test_snapshot_completed_finished_crawl_{str(crawl.id).replace('-', '_')}") - service = SnapshotService(bus, crawl_id=str(crawl.id), schedule_snapshot=lambda snapshot_id: asyncio.sleep(0)) + service = SnapshotService(bus, crawl_id=str(crawl.id)) try: async def emit_completed() -> None: @@ -1123,7 +1133,7 @@ def test_snapshot_completed_event_bus_defers_finished_crawl_seal(): ) bus = create_bus(name=f"test_snapshot_completed_bus_finished_crawl_{str(crawl.id).replace('-', '_')}") - service = SnapshotService(bus, crawl_id=str(crawl.id), schedule_snapshot=lambda snapshot_id: asyncio.sleep(0)) + service = SnapshotService(bus, crawl_id=str(crawl.id)) assert service is not None try: diff --git a/archivebox/tests/test_frozen_crawl_config.py b/archivebox/tests/test_frozen_crawl_config.py index 9ce9f734..fd92ebb4 100644 --- a/archivebox/tests/test_frozen_crawl_config.py +++ b/archivebox/tests/test_frozen_crawl_config.py @@ -172,12 +172,12 @@ def test_search_backend_engine_derives_default_backend_enabled_without_entering_ assert "DATABASE_NAME" not in runtime_config -def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_env_overridden(archivebox_db, monkeypatch): - from archivebox.config.common import get_config +def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_base_config_overridden(archivebox_db): + from archivebox.config.common import ArchiveBoxConfig, get_config from archivebox.crawls.models import Crawl from archivebox.plugins.discovery import get_plugin_special_config - monkeypatch.setenv("ARCHIVEDOTORG_ENABLED", "False") + process_defaults = ArchiveBoxConfig(ARCHIVEDOTORG_ENABLED=False) user = _user("frozen-config-enabled-admin") persona = _persona(user, name="Enabled Persona") @@ -190,8 +190,8 @@ def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_en retry_at=timezone.now(), ) env_default_crawl.save() - env_default_config = get_config(crawl=env_default_crawl, include_machine=False) - assert env_default_config.ARCHIVEDOTORG_ENABLED is False + base_default_config = get_config(crawl=env_default_crawl, include_machine=False, base_config=process_defaults) + assert base_default_config.ARCHIVEDOTORG_ENABLED is False crawl = Crawl( urls="https://example.com/enabled", @@ -206,14 +206,12 @@ def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_en assert crawl.config["PLUGINS"] == "archivedotorg" assert "ARCHIVEDOTORG_ENABLED" not in crawl.config assert "DEFAULT_PERSONA" not in crawl.config - runtime_config = get_config(crawl=crawl, include_machine=False) + runtime_config = get_config(crawl=crawl, include_machine=False, base_config=process_defaults) assert runtime_config.ARCHIVEDOTORG_ENABLED is True assert runtime_config.WGET_ENABLED is False assert get_plugin_special_config("archivedotorg", runtime_config)["enabled"] is True assert get_plugin_special_config("wget", runtime_config)["enabled"] is False - monkeypatch.delenv("ARCHIVEDOTORG_ENABLED") - Crawl.objects.filter(id=crawl.id).update( config={ **crawl.config, @@ -223,7 +221,7 @@ def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_en }, ) crawl.refresh_from_db() - stale_runtime_config = get_config(crawl=crawl, include_machine=False) + stale_runtime_config = get_config(crawl=crawl, include_machine=False, base_config=process_defaults) assert stale_runtime_config.ARCHIVEDOTORG_ENABLED is True assert stale_runtime_config.YTDLP_ENABLED is False assert stale_runtime_config.WGET_ENABLED is False diff --git a/archivebox/tests/test_hooks.py b/archivebox/tests/test_hooks.py index d48b5c92..6f96e848 100755 --- a/archivebox/tests/test_hooks.py +++ b/archivebox/tests/test_hooks.py @@ -10,15 +10,13 @@ Run with: """ import json +import hashlib import os -import shutil import subprocess -import sys -import textwrap +from importlib.resources import files from pathlib import Path import pytest -import rich_click as click from archivebox.tests.conftest import resolve_abxpkg_binary_env @@ -27,70 +25,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "archivebox.settings") REPO_ROOT = Path(__file__).resolve().parents[2] WORKSPACE_ROOT = REPO_ROOT.parent -RESULT_PREFIX = "__ARCHIVEBOX_TEST_RESULT__=" - - -def create_test_plugin_structure(plugins_dir: Path) -> None: - """Create a minimal plugin tree for hook discovery tests.""" - plugins_dir.mkdir() - - wget_dir = plugins_dir / "wget" - wget_dir.mkdir() - (wget_dir / "on_Snapshot__50_wget.py").write_text("# test hook") - - chrome_dir = plugins_dir / "chrome" - chrome_dir.mkdir(exist_ok=True) - (chrome_dir / "on_Snapshot__20_chrome_tab.daemon.bg.js").write_text("// background hook") - - consolelog_dir = plugins_dir / "consolelog" - consolelog_dir.mkdir() - (consolelog_dir / "on_Snapshot__21_consolelog.daemon.bg.js").write_text("// background hook") - - -def run_plugin_discovery_subprocess(tmp_path: Path, plugins_dir: Path, script: str): - env = os.environ.copy() - data_dir = tmp_path / "data" - data_dir.mkdir() - cwd_plugins_dir = data_dir / "custom_plugins" - if plugins_dir != cwd_plugins_dir: - shutil.copytree(plugins_dir, cwd_plugins_dir) - existing_pythonpath = [ - str(Path(entry).expanduser().resolve(strict=False)) - for entry in env.get("PYTHONPATH", "").split(os.pathsep) - if entry and Path(entry).expanduser().is_absolute() - ] - env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys([str(REPO_ROOT), *existing_pythonpath])) - subprocess_script = "\n".join( - [ - "import json", - f"RESULT_PREFIX = {RESULT_PREFIX!r}", - "", - "def emit(value):", - " print(RESULT_PREFIX + json.dumps(value))", - "", - textwrap.dedent(script), - ], - ) - - result = subprocess.run( - [ - sys.executable, - "-c", - subprocess_script, - ], - cwd=data_dir, - env=env, - capture_output=True, - text=True, - timeout=30, - ) - assert result.returncode == 0, result.stderr - - for line in reversed(result.stdout.splitlines()): - if line.startswith(RESULT_PREFIX): - return json.loads(line.removeprefix(RESULT_PREFIX)) - - raise AssertionError(f"Subprocess did not emit a result line.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}") +WGET_CONFIG = Path(str(files("abx_plugins.plugins.wget").joinpath("config.json"))) +CHROME_CONFIG = Path(str(files("abx_plugins.plugins.chrome").joinpath("config.json"))) def test_cli_env_does_not_emit_relative_pythonpath_entries(): @@ -116,104 +52,129 @@ def test_cli_env_does_not_emit_relative_pythonpath_entries(): class TestBackgroundHookDetection: - """Test that background hooks are detected by .bg. suffix.""" + """Test background classification against the shipped hook suite.""" - def test_bg_js_suffix_detected(self): - """Hooks with .bg.js suffix should be detected as background.""" - from archivebox.plugins.hooks import is_background_hook + def test_shipped_hooks_are_classified_by_bg_marker(self): + from archivebox.plugins.hooks import discover_hooks, is_background_hook - assert is_background_hook("on_Snapshot__21_consolelog.daemon.bg.js") + hooks = discover_hooks("Snapshot", filter_disabled=False) + background_hooks = [hook for hook in hooks if is_background_hook(hook.name)] + foreground_hooks = [hook for hook in hooks if not is_background_hook(hook.name)] - def test_bg_py_suffix_detected(self): - """Hooks with .bg.py suffix should be detected as background.""" - from archivebox.plugins.hooks import is_background_hook - - assert is_background_hook("on_Snapshot__24_responses.finite.bg.py") - - def test_bg_sh_suffix_detected(self): - """Hooks with .bg.sh suffix should be detected as background.""" - from archivebox.plugins.hooks import is_background_hook - - assert is_background_hook("on_Snapshot__23_ssl.daemon.bg.sh") - - def test_legacy_background_suffix_detected(self): - """Hooks with __background in stem should be detected (backwards compat).""" - from archivebox.plugins.hooks import is_background_hook - - assert is_background_hook("on_Snapshot__21_consolelog__background.js") - - def test_foreground_hook_not_detected(self): - """Hooks without .bg. or __background should NOT be detected as background.""" - from archivebox.plugins.hooks import is_background_hook - - assert not is_background_hook("on_Snapshot__11_favicon.js") - - def test_foreground_py_hook_not_detected(self): - """Python hooks without .bg. should NOT be detected as background.""" - from archivebox.plugins.hooks import is_background_hook - - assert not is_background_hook("on_Snapshot__50_wget.py") + assert hooks + assert background_hooks + assert foreground_hooks + assert all(".bg." in hook.name for hook in background_hooks) + assert all(".bg." not in hook.name for hook in foreground_hooks) + assert any(hook.name == "on_Snapshot__10_chrome_tab.daemon.bg.js" for hook in background_hooks) + assert any(hook.name == "on_Snapshot__06_wget.finite.bg.py" for hook in background_hooks) + assert any(hook.name == "on_Snapshot__93_hashes.py" for hook in foreground_hooks) +@pytest.mark.django_db(transaction=True) class TestJSONLParsing: - """Test JSONL parsing in run_hook() output processing.""" + """Test JSONL parsing against output from shipped hooks.""" - def test_parse_clean_jsonl(self): - """Clean JSONL format should be parsed correctly.""" - stdout = '{"type": "ArchiveResult", "status": "succeeded", "output_str": "Done"}' + @staticmethod + def run_hashes_hook(tmp_path): + from archivebox.plugins.hooks import run_hook + + snap_dir = tmp_path / "hash-snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real parser input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + process = run_hook( + hook_path, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="https://example.com/hash-parser", + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + return process + + @staticmethod + def run_parser_hook(tmp_path): + from archivebox.plugins.hooks import run_hook + + snap_dir = tmp_path / "parser-snapshot" + staticfile_dir = snap_dir / "staticfile" + output_dir = snap_dir / "parse_txt_urls" + staticfile_dir.mkdir(parents=True) + output_dir.mkdir(parents=True) + (staticfile_dir / "input.txt").write_text( + "links: https://one.example/path and https://two.example/path", + encoding="utf-8", + ) + hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath("on_Snapshot__71_parse_txt_urls.py"))) + process = run_hook( + hook_path, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="file:///input.txt", + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + return process + + def test_parse_clean_jsonl(self, tmp_path): + """Clean JSONL emitted by a shipped hook should be parsed correctly.""" from archivebox.machine.models import Process - records = Process.parse_records_from_text(stdout) + process = self.run_hashes_hook(tmp_path) + records = Process.parse_records_from_text(process.stdout) assert len(records) == 1 assert records[0]["type"] == "ArchiveResult" assert records[0]["status"] == "succeeded" - assert records[0]["output_str"] == "Done" + assert records[0]["output_str"].endswith(hashlib.sha256(b"real parser input").hexdigest()[:12]) - def test_parse_multiple_jsonl_records(self): - """Multiple JSONL records should all be parsed.""" - stdout = """{"type": "ArchiveResult", "status": "succeeded", "output_str": "Done"} -{"type": "Binary", "name": "wget", "abspath": "/usr/bin/wget"}""" + def test_parse_multiple_jsonl_records(self, tmp_path): + """Every record emitted by a shipped parser hook should be parsed.""" from archivebox.machine.models import Process - records = Process.parse_records_from_text(stdout) + process = self.run_parser_hook(tmp_path) + records = Process.parse_records_from_text(process.stdout) - assert len(records) == 2 - assert records[0]["type"] == "ArchiveResult" - assert records[1]["type"] == "Binary" + assert [record["type"] for record in records] == ["Snapshot", "Snapshot", "ArchiveResult"] + assert {record["url"] for record in records[:-1]} == { + "https://one.example/path", + "https://two.example/path", + } + assert records[-1]["status"] == "succeeded" - def test_parse_jsonl_with_log_output(self): - """JSONL should be extracted from mixed stdout with log lines.""" - stdout = """Starting hook execution... -Processing URL: https://example.com -{"type": "ArchiveResult", "status": "succeeded", "output_str": "Downloaded"} -Hook completed successfully""" + def test_parse_jsonl_with_log_output(self, tmp_path): + """JSONL should be extracted from a shipped hook's mixed stdout.""" from archivebox.machine.models import Process + process = self.run_parser_hook(tmp_path) + assert "parsing 1 files for urls..." in process.stdout + assert "2 URLs parsed" in process.stdout + records = Process.parse_records_from_text(process.stdout) + + assert len(records) == 3 + assert records[-1]["status"] == "succeeded" + + def test_ignore_invalid_json(self, tmp_path): + """Malformed non-record lines must not hide real hook records.""" + from archivebox.machine.models import Process + + process = self.run_hashes_hook(tmp_path) + stdout = f"{process.stdout}\n{{invalid json here}}\nnot json at all\n" records = Process.parse_records_from_text(stdout) assert len(records) == 1 - assert records[0]["status"] == "succeeded" + assert records[0]["type"] == "ArchiveResult" - def test_ignore_invalid_json(self): - """Invalid JSON should be silently ignored.""" - stdout = """{"type": "ArchiveResult", "status": "succeeded"} -{invalid json here} -not json at all -{"type": "BinaryRequest", "name": "wget"}""" + def test_json_without_type_ignored(self, tmp_path): + """A non-record object must not hide the shipped hook's real record.""" from archivebox.machine.models import Process - records = Process.parse_records_from_text(stdout) - - assert len(records) == 2 - - def test_json_without_type_ignored(self): - """JSON objects without 'type' field should be ignored.""" - stdout = """{"status": "succeeded", "output_str": "Done"} -{"type": "ArchiveResult", "status": "succeeded"}""" - from archivebox.machine.models import Process - - records = Process.parse_records_from_text(stdout) + process = self.run_hashes_hook(tmp_path) + records = Process.parse_records_from_text(f'{process.stdout}\n{{"status":"succeeded"}}\n') assert len(records) == 1 assert records[0]["type"] == "ArchiveResult" @@ -222,68 +183,53 @@ not json at all class TestRequiredBinaryConfigHandling: """Test that required_binaries keep configured XYZ_BINARY values intact.""" - def test_binary_env_var_absolute_path_handling(self): - """Absolute binary paths should pass through unchanged.""" - configured_binary = "/custom/path/to/wget2" - binary_name = configured_binary + def test_binary_env_var_absolute_path_handling(self, tmp_path): + """abxpkg should expose the resolved binary as an absolute path.""" + resolved = resolve_abxpkg_binary_env(tmp_path / "lib", deps_from=WGET_CONFIG) - assert binary_name == "/custom/path/to/wget2" + assert Path(resolved["WGET_BINARY"]).is_absolute() + assert Path(resolved["WGET_BINARY"]).is_file() - def test_binary_env_var_name_only_handling(self): - """Binary command names should pass through unchanged.""" - configured_binary = "wget2" - binary_name = configured_binary + def test_binary_env_var_name_only_handling(self, tmp_path): + """The projected command name should execute the resolved host binary.""" + lib_dir = tmp_path / "lib" + resolve_abxpkg_binary_env(lib_dir, deps_from=WGET_CONFIG) + projection = lib_dir / "env" / "bin" / "wget" + result = subprocess.run([projection, "--version"], capture_output=True, text=True) - assert binary_name == "wget2" + assert projection.is_symlink() + assert result.returncode == 0, result.stderr + assert "Wget" in result.stdout def test_binary_env_var_empty_default(self): - """Empty configured binary values should keep the schema default.""" - configured_binary = "" - if configured_binary: - binary_name = configured_binary - else: - binary_name = "wget" + """The shipped wget schema should retain wget as its required binary.""" + config = json.loads(files("abx_plugins.plugins.wget").joinpath("config.json").read_text()) - assert binary_name == "wget" + assert config["required_binaries"][0]["name"] == "{WGET_BINARY}" + assert config["properties"]["WGET_BINARY"]["default"] == "wget" class TestHookDiscovery: """Test hook discovery functions.""" - def test_discover_hooks_by_event(self, tmp_path): + def test_discover_hooks_by_event(self): """discover_hooks() should find all hooks for an event.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + from archivebox.plugins.hooks import discover_hooks - hooks = [] - for ext in ("sh", "py", "js"): - pattern = f"*/on_Snapshot__*.{ext}" - hooks.extend(plugins_dir.glob(pattern)) + hooks = discover_hooks("Snapshot", filter_disabled=False) - hooks = sorted(set(hooks), key=lambda p: p.name) - - assert len(hooks) == 3 hook_names = [h.name for h in hooks] - assert "on_Snapshot__20_chrome_tab.daemon.bg.js" in hook_names + assert "on_Snapshot__10_chrome_tab.daemon.bg.js" in hook_names assert "on_Snapshot__21_consolelog.daemon.bg.js" in hook_names - assert "on_Snapshot__50_wget.py" in hook_names + assert "on_Snapshot__06_wget.finite.bg.py" in hook_names + assert all(hook.is_file() for hook in hooks) - def test_discover_hooks_sorted_by_name(self, tmp_path): + def test_discover_hooks_sorted_by_name(self): """Hooks should be sorted by filename (numeric prefix ordering).""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + from archivebox.plugins.hooks import discover_hooks - hooks = [] - for ext in ("sh", "py", "js"): - pattern = f"*/on_Snapshot__*.{ext}" - hooks.extend(plugins_dir.glob(pattern)) - - hooks = sorted(set(hooks), key=lambda p: p.name) - - # Check numeric ordering - assert hooks[0].name == "on_Snapshot__20_chrome_tab.daemon.bg.js" - assert hooks[1].name == "on_Snapshot__21_consolelog.daemon.bg.js" - assert hooks[2].name == "on_Snapshot__50_wget.py" + hook_names = [hook.name for hook in discover_hooks("Snapshot", filter_disabled=False)] + assert hook_names == sorted(hook_names) def test_normalize_hook_event_name_accepts_event_classes(self): """Hook discovery should normalize bus event class names to hook families.""" @@ -303,167 +249,51 @@ class TestHookDiscovery: assert hooks_module.normalize_hook_event_name("SnapshotCleanupEvent") == "SnapshotCleanup" assert hooks_module.normalize_hook_event_name("CrawlCleanupEvent") == "CrawlCleanup" - def test_discover_hooks_skips_plugins_with_disabled_required_dependencies(self, tmp_path): + def test_discover_hooks_skips_plugins_with_disabled_required_dependencies(self): """Plugins whose required_plugins are disabled should not run.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + from archivebox.plugins.hooks import discover_hooks - chrome_dir = plugins_dir / "chrome" - chrome_dir.mkdir(exist_ok=True) - (chrome_dir / "config.json").write_text( - json.dumps( - { - "type": "object", - "required_plugins": [], - "properties": { - "CHROME_ENABLED": { - "type": "boolean", - "default": True, - "x-aliases": ["USE_CHROME"], - }, - }, - }, - ), - ) - (chrome_dir / "on_Snapshot__20_chrome.js").write_text("// chrome hook") - - accessibility_dir = plugins_dir / "accessibility" - accessibility_dir.mkdir(exist_ok=True) - (accessibility_dir / "config.json").write_text( - json.dumps( - { - "type": "object", - "required_plugins": ["chrome"], - "properties": { - "ACCESSIBILITY_ENABLED": { - "type": "boolean", - "default": True, - }, - }, - }, - ), - ) - (accessibility_dir / "on_Snapshot__10_accessibility.js").write_text("// accessibility hook") - - wget_dir = plugins_dir / "wget" - (wget_dir / "config.json").write_text( - json.dumps( - { - "type": "object", - "required_plugins": [], - "properties": { - "WGET_ENABLED": { - "type": "boolean", - "default": True, - "x-aliases": ["SAVE_WGET"], - }, - }, - }, - ), - ) - - hook_names = run_plugin_discovery_subprocess( - tmp_path, - plugins_dir, - """ - from archivebox.plugins import hooks as hooks_module - - hooks = hooks_module.discover_hooks("Snapshot", config={"CHROME_ENABLED": False, "WGET_ENABLED": True}) - emit([hook.parent.name for hook in hooks]) - """, - ) + hook_names = [hook.parent.name for hook in discover_hooks("Snapshot", config={"CHROME_ENABLED": False, "WGET_ENABLED": True})] assert "wget" in hook_names assert "chrome" not in hook_names assert "accessibility" not in hook_names - def test_get_plugins_includes_config_only_plugin_dirs(self, tmp_path): - """get_plugins() should include config-only plugins with standardized metadata.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + def test_get_plugins_includes_config_only_plugin_dirs(self): + """get_plugins() should include shipped plugin directories without hooks.""" + from archivebox.plugins.discovery import BUILTIN_PLUGINS_DIR, get_plugins - helper_dir = plugins_dir / "helper" - helper_dir.mkdir() - (helper_dir / "config.json").write_text('{"type": "object", "properties": {}}') + plugins = get_plugins() + assert "base" in plugins + base_dir = BUILTIN_PLUGINS_DIR / "base" + assert (base_dir / "config.json").is_file() + assert list(base_dir.glob("on_*__*.*")) == [] - plugins = run_plugin_discovery_subprocess( - tmp_path, - plugins_dir, - """ - from archivebox.plugins import hooks as hooks_module - - from archivebox.plugins.discovery import get_plugins - get_plugins.cache_clear() - emit(get_plugins()) - """, - ) - assert "helper" in plugins - - def test_discover_binary_hooks_returns_empty(self, tmp_path): + def test_discover_binary_hooks_returns_empty(self): """Binary provider hooks are owned by abxpkg, not ArchiveBox plugin discovery.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + from archivebox.plugins.hooks import discover_hooks - hook_names = run_plugin_discovery_subprocess( - tmp_path, - plugins_dir, - """ - from archivebox.plugins import hooks as hooks_module - - from archivebox.plugins.discovery import get_plugins - get_plugins.cache_clear() - hooks = hooks_module.discover_hooks("BinaryRequest", filter_disabled=False) - emit([hook.name for hook in hooks]) - """, - ) + hook_names = [hook.name for hook in discover_hooks("BinaryRequest", filter_disabled=False)] assert hook_names == [] - def test_discover_hooks_accepts_event_class_names(self, tmp_path): + def test_discover_hooks_accepts_event_class_names(self): """discover_hooks should accept CrawlSetupEvent / SnapshotEvent class names.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) - chrome_dir = plugins_dir / "chrome" - (chrome_dir / "on_CrawlSetup__90_chrome_launch.daemon.bg.js").write_text("// crawl hook") + from archivebox.plugins.hooks import discover_hooks - hook_names = run_plugin_discovery_subprocess( - tmp_path, - plugins_dir, - """ - from archivebox.plugins import hooks as hooks_module - - from archivebox.plugins.discovery import get_plugins - get_plugins.cache_clear() - crawl_setup_hooks = hooks_module.discover_hooks("CrawlSetupEvent", filter_disabled=False) - snapshot_hooks = hooks_module.discover_hooks("SnapshotEvent", filter_disabled=False) - emit({ - "crawl_setup": [hook.name for hook in crawl_setup_hooks], - "snapshot": [hook.name for hook in snapshot_hooks], - }) - """, - ) + hook_names = { + "crawl_setup": [hook.name for hook in discover_hooks("CrawlSetupEvent", filter_disabled=False)], + "snapshot": [hook.name for hook in discover_hooks("SnapshotEvent", filter_disabled=False)], + } assert "on_CrawlSetup__90_chrome_launch.daemon.bg.js" in hook_names["crawl_setup"] - assert "on_Snapshot__50_wget.py" in hook_names["snapshot"] + assert "on_Snapshot__06_wget.finite.bg.py" in hook_names["snapshot"] - def test_discover_hooks_returns_empty_for_non_hook_lifecycle_events(self, tmp_path): + def test_discover_hooks_returns_empty_for_non_hook_lifecycle_events(self): """Lifecycle events without a hook family should return no hooks.""" - plugins_dir = tmp_path / "plugins" - create_test_plugin_structure(plugins_dir) + from archivebox.plugins.hooks import discover_hooks - hooks = run_plugin_discovery_subprocess( - tmp_path, - plugins_dir, - """ - from archivebox.plugins import hooks as hooks_module - - from archivebox.plugins.discovery import get_plugins - get_plugins.cache_clear() - emit({ - "binary": [hook.name for hook in hooks_module.discover_hooks("BinaryEvent", filter_disabled=False)], - "crawl_cleanup": [ - hook.name for hook in hooks_module.discover_hooks("CrawlCleanupEvent", filter_disabled=False) - ], - }) - """, - ) + hooks = { + "binary": [hook.name for hook in discover_hooks("BinaryEvent", filter_disabled=False)], + "crawl_cleanup": [hook.name for hook in discover_hooks("CrawlCleanupEvent", filter_disabled=False)], + } assert hooks["binary"] == [] assert hooks["crawl_cleanup"] == [] @@ -503,20 +333,28 @@ class TestHookExecution: def test_python_hook_execution(self, tmp_path): """Python hook should execute and output JSONL.""" - hook_path = tmp_path / "test_hook.py" - hook_path.write_text("""#!/usr/bin/env python3 -import json -print(json.dumps({"type": "ArchiveResult", "status": "succeeded", "output_str": "Test passed"})) -""") - - result = subprocess.run( - [sys.executable, str(hook_path)], - cwd=tmp_path, - capture_output=True, - text=True, + snap_dir = tmp_path / "snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real hook input", encoding="utf-8") + hook_path = Path( + str( + files("abx_plugins.plugins.hashes").joinpath( + "on_Snapshot__93_hashes.py", + ), + ), ) - assert result.returncode == 0 + result = subprocess.run( + [str(hook_path), "--url=https://example.com"], + cwd=output_dir, + capture_output=True, + text=True, + env={**os.environ, "SNAP_DIR": str(snap_dir)}, + timeout=30, + ) + + assert result.returncode == 0, result.stderr from archivebox.machine.models import Process records = Process.parse_records_from_text(result.stdout) @@ -525,212 +363,213 @@ print(json.dumps({"type": "ArchiveResult", "status": "succeeded", "output_str": assert records[0]["status"] == "succeeded" def test_js_hook_execution(self, tmp_path): - """JavaScript hook should execute and output JSONL.""" + """A shipped JavaScript hook should execute through projected Node.""" lib_dir = tmp_path / "lib" - node_env = resolve_abxpkg_binary_env(lib_dir, "node") + chrome_config = Path( + str(files("abx_plugins.plugins.chrome").joinpath("config.json")), + ) + node_env = resolve_abxpkg_binary_env( + lib_dir, + deps_from=chrome_config, + ) node_binary = lib_dir / "env" / "bin" / "node" assert node_binary.is_symlink() - hook_path = tmp_path / "test_hook.js" - hook_path.write_text("""#!/usr/bin/env node -console.log(JSON.stringify({type: 'ArchiveResult', status: 'succeeded', output_str: 'JS test'})); -""") + crawl_dir = tmp_path / "crawl" + output_dir = crawl_dir / "chrome" + output_dir.mkdir(parents=True) + hook_path = Path( + str( + files("abx_plugins.plugins.chrome").joinpath( + "on_CrawlSetup__89_chrome_kill_zombies.js", + ), + ), + ) result = subprocess.run( [str(node_binary), str(hook_path)], - cwd=tmp_path, + cwd=output_dir, capture_output=True, text=True, - env={**os.environ, **node_env}, + env={ + **os.environ, + **node_env, + "CRAWL_DIR": str(crawl_dir), + "SNAP_DIR": str(crawl_dir / "snapshot"), + "CHROME_USER_DATA_DIR": str(output_dir / "profile"), + }, + timeout=30, ) - assert result.returncode == 0 - from archivebox.machine.models import Process + assert result.returncode == 0, result.stderr + assert "chrome zombies" in result.stdout - records = Process.parse_records_from_text(result.stdout) - assert records - assert records[0]["type"] == "ArchiveResult" - assert records[0]["status"] == "succeeded" + @pytest.mark.django_db(transaction=True) + def test_real_js_hook_runs_through_abxpkg_node_projection(self, tmp_path): + from archivebox.plugins.hooks import run_hook + + lib_dir = tmp_path / "lib" + node_env = resolve_abxpkg_binary_env(lib_dir, deps_from=CHROME_CONFIG) + node_projection = lib_dir / "env" / "bin" / "node" + crawl_dir = tmp_path / "crawl" + snap_dir = crawl_dir / "snapshot" + hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js"))) + + process = run_hook( + hook_path, + crawl_dir / "chrome", + config={ + **node_env, + "ABXPKG_LIB_DIR": str(lib_dir), + "CRAWL_DIR": str(crawl_dir), + "SNAP_DIR": str(snap_dir), + "CHROME_USER_DATA_DIR": str(crawl_dir / "chrome" / "profile"), + }, + timeout=30, + ) + process.refresh_from_db() + + assert process.cmd[0] == str(node_projection) + assert process.exit_code == 0, process.stderr + assert "chrome zombies" in process.stdout def test_hook_receives_cli_args(self, tmp_path): """Hook should receive CLI arguments.""" - hook_path = tmp_path / "test_hook.py" - hook_path.write_text("""#!/usr/bin/env python3 -import sys -import json -# Simple arg parsing -args = {} -for arg in sys.argv[1:]: - if arg.startswith('--') and '=' in arg: - key, val = arg[2:].split('=', 1) - args[key.replace('-', '_')] = val -print(json.dumps({"type": "ArchiveResult", "status": "succeeded", "url": args.get("url", "")})) -""") - - result = subprocess.run( - [sys.executable, str(hook_path), "--url=https://example.com"], - cwd=tmp_path, - capture_output=True, - text=True, + snap_dir = tmp_path / "snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real CLI argument input", encoding="utf-8") + hook_path = Path( + str( + files("abx_plugins.plugins.hashes").joinpath( + "on_Snapshot__93_hashes.py", + ), + ), ) - assert result.returncode == 0 + result = subprocess.run( + [str(hook_path), "--url=https://example.com/real-hook-argument"], + cwd=output_dir, + capture_output=True, + text=True, + env={**os.environ, "SNAP_DIR": str(snap_dir)}, + timeout=30, + ) + + assert result.returncode == 0, result.stderr from archivebox.machine.models import Process records = Process.parse_records_from_text(result.stdout) - assert records - assert records[0]["url"] == "https://example.com" + source_hash = hashlib.sha256(b"real CLI argument input").hexdigest() + assert records == [{"type": "ArchiveResult", "status": "succeeded", "output_str": f"0.0MB {source_hash[:12]}"}] + hashes = json.loads((output_dir / "hashes.json").read_text()) + assert hashes["files"][0]["hash"] == source_hash class TestDependencyRecordOutput: - """Test dependency record output format compliance.""" + """Test Binary JSONL emitted by the real CLI and persisted model.""" - def test_dependency_record_outputs_binary(self): - """Dependency resolution should output Binary JSONL when binary is found.""" - hook_output = json.dumps( - { - "type": "Binary", - "name": "wget", - "abspath": "/usr/bin/wget", - "version": "1.21.3", - "sha256": None, - "binprovider": "apt", - }, + @pytest.mark.django_db(transaction=True) + def test_binary_cli_emits_resolved_dependency_record(self, initialized_archive, tmp_path): + wget_path = resolve_abxpkg_binary_env(tmp_path / "lib", deps_from=WGET_CONFIG)["WGET_BINARY"] + version = subprocess.run([wget_path, "--version"], capture_output=True, text=True, check=True).stdout.split()[2] + from archivebox.tests.conftest import parse_jsonl_output, run_archivebox_cmd + + result = run_archivebox_cmd( + ["binary", "create", "--name=wget", f"--abspath={wget_path}", f"--version={version}"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, ) + assert result.returncode == 0, result.stderr - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] + data = parse_jsonl_output(result.stdout)[0] assert data["type"] == "Binary" assert data["name"] == "wget" - assert data["abspath"].startswith("/") + assert data["abspath"] == wget_path + assert data["version"] == version - def test_dependency_record_outputs_binary_jsonl(self): - """Dependency resolution should output Binary JSONL.""" - hook_output = json.dumps( - { - "type": "Binary", - "name": "wget", - "abspath": "/usr/bin/wget", - "version": "1.21.3", - "binprovider": "env", - }, + list_result = run_archivebox_cmd( + ["binary", "list", "--name=wget"], + cwd=initialized_archive, + default_cli_env=True, + disable_extractors=True, ) - - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["type"] == "Binary" - assert data["name"] == "wget" - assert data["abspath"] == "/usr/bin/wget" + assert list_result.returncode == 0, list_result.stderr + listed = parse_jsonl_output(list_result.stdout) + assert any(record["id"] == data["id"] and record["abspath"] == wget_path for record in listed) class TestSnapshotHookOutput: - """Test snapshot hook output format compliance.""" + """Test ArchiveResult records emitted by a shipped snapshot hook.""" - def test_snapshot_hook_basic_output(self): - """Snapshot hook should output clean ArchiveResult JSONL.""" - hook_output = json.dumps( - { - "type": "ArchiveResult", - "status": "succeeded", - "output_str": "Downloaded 5 files", + @pytest.mark.django_db(transaction=True) + @pytest.mark.parametrize( + ("enabled", "expected_status"), + [(True, "succeeded"), (False, "skipped")], + ) + def test_hashes_hook_emits_real_archive_result(self, tmp_path, enabled, expected_status): + from archivebox.plugins.hooks import extract_records_from_process, run_hook + + snap_dir = tmp_path / f"snapshot-{expected_status}" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real hook protocol input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + + process = run_hook( + hook_path, + output_dir, + config={ + "ABXPKG_LIB_DIR": str(tmp_path / "lib"), + "SNAP_DIR": str(snap_dir), + "HASHES_ENABLED": enabled, }, + timeout=30, + url="https://example.com/real-hook-record", ) + process.refresh_from_db() - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["type"] == "ArchiveResult" - assert data["status"] == "succeeded" - assert "output_str" in data - - def test_snapshot_hook_with_cmd(self): - """Snapshot hook should include cmd for binary FK lookup.""" - hook_output = json.dumps( - { - "type": "ArchiveResult", - "status": "succeeded", - "output_str": "Archived with wget", - "cmd": ["/usr/bin/wget", "-p", "-k", "https://example.com"], - }, - ) - - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["type"] == "ArchiveResult" - assert isinstance(data["cmd"], list) - assert data["cmd"][0] == "/usr/bin/wget" - - def test_snapshot_hook_with_output_json(self): - """Snapshot hook can include structured metadata in output_json.""" - hook_output = json.dumps( - { - "type": "ArchiveResult", - "status": "succeeded", - "output_str": "Got headers", - "output_json": { - "content-type": "text/html", - "server": "nginx", - "status-code": 200, - }, - }, - ) - - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["type"] == "ArchiveResult" - assert isinstance(data["output_json"], dict) - assert data["output_json"]["status-code"] == 200 - - def test_snapshot_hook_skipped_status(self): - """Snapshot hook should support skipped status.""" - hook_output = json.dumps( - { - "type": "ArchiveResult", - "status": "skipped", - "output_str": "SAVE_WGET=False", - }, - ) - - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["status"] == "skipped" - - def test_snapshot_hook_failed_status(self): - """Snapshot hook should support failed status.""" - hook_output = json.dumps( - { - "type": "ArchiveResult", - "status": "failed", - "output_str": "404 Not Found", - }, - ) - - from archivebox.machine.models import Process - - data = Process.parse_records_from_text(hook_output)[0] - assert data["status"] == "failed" + assert process.exit_code == 0, process.stderr + records = extract_records_from_process(process) + assert len(records) == 1 + assert records[0]["type"] == "ArchiveResult" + assert records[0]["status"] == expected_status + assert records[0]["plugin"] == "hashes" + assert records[0]["hook_name"] == hook_path.name + assert records[0]["plugin_hook"] == str(hook_path) + if enabled: + assert (output_dir / "hashes.json").is_file() + else: + assert records[0]["output_str"] == "HASHES_ENABLED=False" class TestPluginMetadata: """Test that plugin metadata is added to JSONL records.""" - def test_plugin_name_added(self): - """run_hook() should add plugin name to records.""" - # Simulate what run_hook() does - script = Path("/abx_plugins/plugins/wget/on_Snapshot__50_wget.py") - plugin_name = script.parent.name + @pytest.mark.django_db(transaction=True) + def test_python_hook_metadata_comes_from_executed_shipped_hook(self, tmp_path): + from archivebox.plugins.hooks import extract_records_from_process, run_hook - record = {"type": "ArchiveResult", "status": "succeeded"} - record["plugin"] = plugin_name - record["plugin_hook"] = str(script) + snap_dir = tmp_path / "snapshot-metadata" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("metadata", encoding="utf-8") + script = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + process = run_hook( + script, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="https://example.com/metadata", + ) + process.refresh_from_db() - assert record["plugin"] == "wget" - assert "on_Snapshot__50_wget.py" in record["plugin_hook"] + assert process.exit_code == 0, process.stderr + records = extract_records_from_process(process) + assert records[0]["plugin"] == "hashes" + assert records[0]["hook_name"] == script.name + assert records[0]["plugin_hook"] == str(script) @pytest.mark.django_db(transaction=True) @@ -739,93 +578,67 @@ def test_run_hook_exports_singular_node_modules_dir_with_colon_node_path(tmp_pat from archivebox.plugins.hooks import run_hook lib_dir = tmp_path / "lib" - node_modules_dir = lib_dir / "pnpm" / "packages" / "chrome" / "node_modules" - configured_node_path = os.pathsep.join( - [ - "/home/archivebox/.pnpm/packages/chrome/node_modules", - "/usr/lib/node_modules", - str(node_modules_dir), - "/usr/share/archivebox/lib/pnpm/packages/chrome/node_modules", - ], + chrome_config = Path(str(files("abx_plugins.plugins.chrome").joinpath("config.json"))) + node_env = resolve_abxpkg_binary_env( + lib_dir, + deps_from=chrome_config, ) - - plugin_dir = tmp_path / "plugins" / "envprobe" - plugin_dir.mkdir(parents=True) - hook_path = plugin_dir / "on_Snapshot__99_envprobe.py" - hook_path.write_text( - """#!/usr/bin/env python3 -import json -import os - -print(json.dumps({ - "NODE_PATH": os.environ.get("NODE_PATH"), - "NODE_MODULES_DIR": os.environ.get("NODE_MODULES_DIR"), - "NODE_MODULE_DIR": os.environ.get("NODE_MODULE_DIR"), -})) -""", - encoding="utf-8", - ) - hook_path.chmod(0o755) - - output_dir = tmp_path / "archive" / "users" / "system" / "snapshots" / "20260513" / "example.com" / "test" / "envprobe" + configured_node_path = node_env["NODE_PATH"] + node_modules_dir = Path(node_env["NODE_MODULES_DIR"]) + crawl_dir = tmp_path / "crawl" + output_dir = crawl_dir / "chrome" + hook_path = Path(str(files("abx_plugins.plugins.chrome").joinpath("on_CrawlSetup__89_chrome_kill_zombies.js"))) process = run_hook( hook_path, output_dir, config={ + **node_env, "ABXPKG_LIB_DIR": str(lib_dir), - "NODE_PATH": configured_node_path, + "CRAWL_DIR": str(crawl_dir), + "SNAP_DIR": str(crawl_dir / "snapshot"), + "CHROME_USER_DATA_DIR": str(output_dir / "profile"), }, - timeout=10, + timeout=30, ) process.refresh_from_db() assert process.exit_code == 0, process.stderr - payload = json.loads(process.stdout.strip()) - assert payload["NODE_MODULES_DIR"] == str(node_modules_dir) - assert payload["NODE_MODULE_DIR"] == str(node_modules_dir) - assert payload["NODE_PATH"].split(os.pathsep) == configured_node_path.split(os.pathsep) assert process.env["NODE_MODULES_DIR"] == str(node_modules_dir) + assert process.env["NODE_MODULE_DIR"] == str(node_modules_dir) + assert process.env["NODE_PATH"].split(os.pathsep) == configured_node_path.split(os.pathsep) + assert "chrome zombies" in process.stdout @pytest.mark.django_db(transaction=True) def test_run_hook_executes_python_hooks_through_script_shebang(tmp_path): - """Python hooks must use their abxpkg script header instead of sys.executable.""" + """Python hooks must execute through their abxpkg script header.""" from archivebox.plugins.hooks import run_hook - plugin_dir = tmp_path / "plugins" / "shebangprobe" - plugin_dir.mkdir(parents=True) - hook_path = plugin_dir / "on_Snapshot__99_shebangprobe.py" - hook_path.write_text( - """#!/usr/bin/env -S abxpkg run --script python3 -# /// script -# requires-python = ">=3.12" -# /// -import json -import os -import rich_click - -print(json.dumps({ - "ABXPKG_FAST_SCRIPT": os.environ.get("ABXPKG_FAST_SCRIPT"), - "RICH_CLICK_FILE": rich_click.__file__, -})) -""", - encoding="utf-8", - ) - hook_path.chmod(0o755) - - output_dir = tmp_path / "archive" / "users" / "system" / "snapshots" / "20260603" / "example.com" / "test" / "shebangprobe" + snap_dir = tmp_path / "snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real shebang hook input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) process = run_hook( hook_path, output_dir, config={ "ABXPKG_LIB_DIR": str(tmp_path / "lib"), + "SNAP_DIR": str(snap_dir), }, - timeout=10, + timeout=30, + url="https://example.com/shebang", ) process.refresh_from_db() assert process.cmd[0] == str(hook_path) assert process.exit_code == 0, process.stderr - payload = json.loads(process.stdout.strip()) - assert payload["ABXPKG_FAST_SCRIPT"] == "1" - assert Path(payload["RICH_CLICK_FILE"]).resolve() == Path(click.__file__).resolve() + assert process.env["ABXPKG_FAST_SCRIPT"] == "1" + records = process.parse_records_from_text(process.stdout) + source_hash = hashlib.sha256(b"real shebang hook input").hexdigest() + assert records == [{"type": "ArchiveResult", "status": "succeeded", "output_str": f"0.0MB {source_hash[:12]}"}] + hashes = json.loads((output_dir / "hashes.json").read_text()) + source = hashes["files"][0] + assert source["path"] == "source.txt" + assert source["size"] == len("real shebang hook input") + assert source["hash"] == source_hash diff --git a/archivebox/tests/test_machine_models.py b/archivebox/tests/test_machine_models.py index 8c3c29cf..2c8b8901 100644 --- a/archivebox/tests/test_machine_models.py +++ b/archivebox/tests/test_machine_models.py @@ -14,7 +14,7 @@ Tests cover: import os import subprocess import sys -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path from typing import cast @@ -38,7 +38,44 @@ from archivebox.machine.models import ( from archivebox.machine.detect import unknown_if_blank from archivebox.tests.conftest import resolve_abxpkg_binary_env -pytestmark = pytest.mark.django_db +pytestmark = pytest.mark.django_db(transaction=True) + + +def _current_process_started_at(): + import psutil + + return datetime.fromtimestamp( + psutil.Process(os.getpid()).create_time(), + tz=timezone.get_current_timezone(), + ) + + +def _spawn_blocked_process(binary_abspath: str): + import psutil + + process = subprocess.Popen( + [binary_abspath, "-c", "print('READY', flush=True); input()"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert process.stdout is not None + assert process.stdout.readline() == "READY\n" + started_at = datetime.fromtimestamp( + psutil.Process(process.pid).create_time(), + tz=timezone.get_current_timezone(), + ) + return process, started_at + + +def _reaped_process_identity(binary_abspath: str) -> tuple[int, datetime]: + process, started_at = _spawn_blocked_process(binary_abspath) + assert process.stdin is not None + process.stdin.write("\n") + process.stdin.flush() + assert process.wait(timeout=5) == 0 + return process.pid, started_at def _reset_machine_model_caches(): @@ -64,20 +101,37 @@ def machine(): @pytest.fixture def binary(machine): - return Binary.objects.create( + from archivebox.tests.conftest import install_real_binary + + return install_real_binary("python", machine=machine) + + +@pytest.fixture +def process(machine, binary, tmp_path): + return Process.objects.create( machine=machine, - name="test-binary", - binproviders="env", + binary=binary, + cmd=[binary.abspath, "--version"], + pwd=str(tmp_path), ) @pytest.fixture -def process(machine): - return Process.objects.create( - machine=machine, - cmd=["echo", "test"], - pwd="/tmp", - ) +def live_process_identity_factory(binary): + processes = [] + + def spawn() -> tuple[int, datetime]: + process, started_at = _spawn_blocked_process(binary.abspath) + processes.append(process) + return process.pid, started_at + + yield spawn + for process in processes: + if process.poll() is None: + assert process.stdin is not None + process.stdin.write("\n") + process.stdin.flush() + assert process.wait(timeout=5) == 0 @pytest.fixture @@ -343,6 +397,7 @@ class TestNetworkInterfaceModel: ) +@pytest.mark.django_db(transaction=True) class TestBinaryModel: """Test the Binary model.""" @@ -351,52 +406,41 @@ class TestBinaryModel: self.machine = machine def test_binary_creation(self): - """Binary should be created with default values.""" - binary = Binary.objects.create( - machine=self.machine, - name="wget", - binproviders="apt,brew,env", - ) + """A resolved Binary should persist its detected installation.""" + from archivebox.tests.conftest import install_real_binary + + binary = install_real_binary("python", machine=self.machine) assert binary.id is not None - assert binary.name == "wget" - assert binary.status == Binary.StatusChoices.QUEUED - assert not binary.is_valid + assert binary.name == "python" + assert binary.status == Binary.StatusChoices.INSTALLED + assert binary.is_valid def test_binary_is_valid(self): """Binary.is_valid should be True for installed binaries with a resolved path.""" - binary = Binary.objects.create( - machine=self.machine, - name="python", - abspath=sys.executable, - version=f"{sys.version_info.major}.{sys.version_info.minor}", - status=Binary.StatusChoices.INSTALLED, - ) + from archivebox.tests.conftest import install_real_binary + + binary = install_real_binary("python", machine=self.machine) assert binary.is_valid def test_binary_manager_get_valid_binary(self): """BinaryManager.get_valid_binary() should find valid binaries.""" - # Create invalid binary (no abspath) - Binary.objects.create(machine=self.machine, name="python") + from archivebox.tests.conftest import install_real_binary - # Create valid binary - Binary.objects.create( - machine=self.machine, - name="python", - abspath=sys.executable, - version=f"{sys.version_info.major}.{sys.version_info.minor}", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("python", machine=self.machine) result = cast(BinaryManager, Binary.objects).get_valid_binary("python") assert result is not None - assert result.abspath == sys.executable + assert result.id == binary.id + assert Path(result.abspath).resolve() == Path(sys.executable).resolve() def test_binary_update_and_requeue(self): """Binary.update_and_requeue() should update fields and save.""" - binary = Binary.objects.create(machine=self.machine, name="test") + from archivebox.tests.conftest import install_real_binary + + binary = install_real_binary("python", machine=self.machine) old_modified = binary.modified_at binary.update_and_requeue( @@ -416,86 +460,41 @@ class TestBinaryModel: "custom": {"install": "bash -lc 'echo ok'"}, } - binary = Binary.from_json( - { - "name": "chrome", - "binproviders": "apt,pnpm,custom", - "overrides": overrides, - }, - ) + from archivebox.tests.conftest import install_real_binary + + binary = install_real_binary("python", machine=self.machine, overrides=overrides) assert binary is not None assert binary.overrides == overrides - def test_binary_from_json_canonicalizes_path_like_names(self): - """Binary.from_json() should store command names, not path cache values.""" - binary = Binary.from_json( - { - "name": "/tmp/old-lib/pip/venv/bin/trafilatura", - "binproviders": "env,pip", - "overrides": {"pip": {"install_args": ["trafilatura"]}}, - }, - ) + def test_binary_from_json_preserves_provider_package_metadata(self): + """A real Binary install should preserve provider-specific package metadata.""" + from archivebox.tests.conftest import install_real_binary - assert binary is not None - assert binary.name == "trafilatura" - - def test_binary_from_json_does_not_coerce_legacy_override_shapes(self): - """Binary.from_json() should no longer translate legacy non-dict provider overrides.""" - overrides = { - "apt": ["chromium"], - "pnpm": "puppeteer", - } - - binary = Binary.from_json( - { - "name": "chrome", - "binproviders": "apt,pnpm", - "overrides": overrides, - }, - ) - - assert binary is not None - assert binary.overrides == overrides - - def test_binary_from_json_preserves_readability_package_metadata(self): - """Binary.from_json() should preserve readability's pnpm package metadata.""" - binary = Binary.from_json( - { - "name": "readability-extractor", - "binproviders": "env,pnpm", - "overrides": { - "pnpm": { - "install_args": ["readability-extractor"], - }, - }, - }, + binary = install_real_binary( + "python", + machine=self.machine, + overrides={"pip": {"install_args": ["python"]}}, ) assert binary is not None assert binary.overrides == { - "pnpm": { - "install_args": ["readability-extractor"], + "pip": { + "install_args": ["python"], }, } @pytest.mark.django_db(transaction=True) def test_binary_lib_bin_symlink_waits_for_outer_transaction_commit(self, tmp_path): """Binary DB projection writes can be direct, but convenience symlinks must run after commit.""" - provider_lib = tmp_path / "provider" - resolve_abxpkg_binary_env(provider_lib, "node") - source = provider_lib / "env" / "bin" / "node" + from archivebox.tests.conftest import install_real_binary + + binary = install_real_binary("python", machine=self.machine) + source = Path(binary.abspath) lib_bin_dir = tmp_path / "lib" / "bin" - symlink = lib_bin_dir / "abx-test-binary" + symlink = lib_bin_dir / "python" with transaction.atomic(): - binary = Binary.objects.create( - machine=self.machine, - name="abx-test-binary", - abspath=str(source), - version="1.0.0", - status=Binary.StatusChoices.INSTALLED, - ) binary.symlink_to_lib_bin_after_commit(lib_bin_dir) assert not symlink.exists() @@ -530,19 +529,22 @@ class TestProcessModel: """Test the Process model.""" @pytest.fixture(autouse=True) - def setup_machine(self, machine): + def setup_machine(self, machine, binary, tmp_path): self.machine = machine + self.binary = binary + self.pwd = str(tmp_path) def test_process_creation(self): """Process should be created with default values.""" process = Process.objects.create( machine=self.machine, - cmd=["echo", "hello"], - pwd="/tmp", + binary=self.binary, + cmd=[self.binary.abspath, "--version"], + pwd=self.pwd, ) assert process.id is not None - assert process.cmd == ["echo", "hello"] + assert process.cmd == [self.binary.abspath, "--version"] assert process.status == Process.StatusChoices.QUEUED assert process.pid is None assert process.exit_code is None @@ -551,30 +553,36 @@ class TestProcessModel: """Process.to_json() should serialize correctly.""" process = Process.objects.create( machine=self.machine, - cmd=["echo", "hello"], - pwd="/tmp", + binary=self.binary, + cmd=[self.binary.abspath, "--version"], + pwd=self.pwd, timeout=60, ) json_data = process.to_json() assert json_data["type"] == "Process" - assert json_data["cmd"] == ["echo", "hello"] - assert json_data["pwd"] == "/tmp" + assert json_data["cmd"] == [self.binary.abspath, "--version"] + assert json_data["pwd"] == self.pwd assert json_data["timeout"] == 60 def test_process_update_and_requeue(self): """Process.update_and_requeue() should update fields and save.""" - process = Process.objects.create(machine=self.machine, cmd=["test"]) + process = Process.objects.create( + machine=self.machine, + binary=self.binary, + cmd=[self.binary.abspath, "--version"], + pwd=self.pwd, + ) process.update_and_requeue( status=Process.StatusChoices.RUNNING, - pid=12345, - started_at=timezone.now(), + pid=os.getpid(), + started_at=_current_process_started_at(), ) process.refresh_from_db() assert process.status == Process.StatusChoices.RUNNING - assert process.pid == 12345 + assert process.pid == os.getpid() assert process.started_at is not None @@ -640,33 +648,23 @@ class TestProcessCurrent: finally: sys.argv = old_argv - def test_process_proc_allows_interpreter_wrapped_script(self, tmp_path): + def test_process_proc_allows_interpreter_wrapped_script(self, binary): """Process.proc should accept a script recorded in DB when wrapped by an interpreter in psutil.""" import psutil - script = tmp_path / "on_CrawlSetup__90_chrome_launch.daemon.bg.py" - script.write_text("import time\ntime.sleep(30)\n", encoding="utf-8") + script = Path(__file__).parents[1] / "cli" / "archivebox_manage.py" process = subprocess.Popen( - [sys.executable, str(script), "--url=https://example.com/"], - stdin=subprocess.DEVNULL, + [binary.abspath, str(script), "shell"], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - def cleanup_process(): - if process.poll() is None: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - try: os_proc = psutil.Process(process.pid) proc = Process.objects.create( machine=Machine.current(), - cmd=[str(script), "--url=https://example.com/"], + cmd=[str(script), "shell"], pid=process.pid, status=Process.StatusChoices.RUNNING, started_at=timezone.datetime.fromtimestamp(os_proc.create_time(), tz=timezone.get_current_timezone()), @@ -676,7 +674,9 @@ class TestProcessCurrent: assert resolved_proc is not None assert resolved_proc.pid == process.pid finally: - cleanup_process() + assert process.stdin is not None + process.stdin.close() + process.wait(timeout=30) class TestProcessHierarchy: @@ -686,14 +686,16 @@ class TestProcessHierarchy: def setup_machine(self, machine): self.machine = machine - def test_process_parent_child(self): + def test_process_parent_child(self, live_process_identity_factory): """Process should track parent/child relationships.""" + parent_pid, parent_started_at = live_process_identity_factory() + child_pid, child_started_at = live_process_identity_factory() parent = Process.objects.create( machine=self.machine, process_type=Process.TypeChoices.CLI, status=Process.StatusChoices.RUNNING, - pid=1, - started_at=timezone.now(), + pid=parent_pid, + started_at=parent_started_at, ) child = Process.objects.create( @@ -701,50 +703,60 @@ class TestProcessHierarchy: parent=parent, process_type=Process.TypeChoices.WORKER, status=Process.StatusChoices.RUNNING, - pid=2, - started_at=timezone.now(), + pid=child_pid, + started_at=child_started_at, ) assert child.parent == parent assert child in parent.children.all() - def test_process_root(self): + def test_process_root(self, live_process_identity_factory): """Process.root should return the root of the hierarchy.""" + root_pid, root_started_at = live_process_identity_factory() + child_pid, child_started_at = live_process_identity_factory() + grandchild_pid, grandchild_started_at = live_process_identity_factory() root = Process.objects.create( machine=self.machine, process_type=Process.TypeChoices.CLI, status=Process.StatusChoices.RUNNING, - started_at=timezone.now(), + pid=root_pid, + started_at=root_started_at, ) child = Process.objects.create( machine=self.machine, parent=root, status=Process.StatusChoices.RUNNING, - started_at=timezone.now(), + pid=child_pid, + started_at=child_started_at, ) grandchild = Process.objects.create( machine=self.machine, parent=child, status=Process.StatusChoices.RUNNING, - started_at=timezone.now(), + pid=grandchild_pid, + started_at=grandchild_started_at, ) assert grandchild.root == root assert child.root == root assert root.root == root - def test_process_depth(self): + def test_process_depth(self, live_process_identity_factory): """Process.depth should return depth in tree.""" + root_pid, root_started_at = live_process_identity_factory() + child_pid, child_started_at = live_process_identity_factory() root = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, - started_at=timezone.now(), + pid=root_pid, + started_at=root_started_at, ) child = Process.objects.create( machine=self.machine, parent=root, status=Process.StatusChoices.RUNNING, - started_at=timezone.now(), + pid=child_pid, + started_at=child_started_at, ) assert root.depth == 0 @@ -755,42 +767,41 @@ class TestProcessLifecycle: """Test Process lifecycle methods.""" @pytest.fixture(autouse=True) - def setup_machine(self, machine): + def setup_machine(self, machine, binary): self.machine = machine + self.binary = binary def test_process_is_running_current_pid(self): """is_running should be True for current PID.""" - import psutil - from datetime import datetime - - proc_start = datetime.fromtimestamp(psutil.Process(os.getpid()).create_time(), tz=timezone.get_current_timezone()) proc = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, pid=os.getpid(), - started_at=proc_start, + started_at=_current_process_started_at(), ) assert proc.is_running - def test_process_is_running_fake_pid(self): - """is_running should be False for non-existent PID.""" + def test_process_is_running_reaped_process(self): + """is_running should be False after the recorded OS process exits.""" + pid, started_at = _reaped_process_identity(self.binary.abspath) proc = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, - pid=999999, - started_at=timezone.now(), + pid=pid, + started_at=started_at, ) assert not proc.is_running def test_process_poll_detects_exit(self): """poll() should detect exited process.""" + pid, started_at = _reaped_process_identity(self.binary.abspath) proc = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, - pid=999999, - started_at=timezone.now(), + pid=pid, + started_at=started_at, ) exit_code = proc.poll() @@ -801,12 +812,13 @@ class TestProcessLifecycle: def test_process_poll_normalizes_negative_exit_code(self): """poll() should normalize -1 exit codes to 137.""" + pid, started_at = _reaped_process_identity(self.binary.abspath) proc = Process.objects.create( machine=self.machine, status=Process.StatusChoices.EXITED, - pid=999999, + pid=pid, exit_code=-1, - started_at=timezone.now(), + started_at=started_at, ) exit_code = proc.poll() @@ -817,11 +829,12 @@ class TestProcessLifecycle: def test_process_terminate_dead_process(self): """terminate() should handle already-dead process.""" + pid, started_at = _reaped_process_identity(self.binary.abspath) proc = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, - pid=999999, - started_at=timezone.now(), + pid=pid, + started_at=started_at, ) result = proc.terminate() @@ -835,8 +848,9 @@ class TestProcessClassMethods: """Test Process class methods for querying.""" @pytest.fixture(autouse=True) - def setup_machine(self, machine): + def setup_machine(self, machine, binary): self.machine = machine + self.binary = binary def test_get_running(self): """get_running should return running processes.""" @@ -844,8 +858,8 @@ class TestProcessClassMethods: machine=self.machine, process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, - pid=99999, - started_at=timezone.now(), + pid=os.getpid(), + started_at=_current_process_started_at(), ) running = Process.get_running(process_type=Process.TypeChoices.HOOK) @@ -859,8 +873,8 @@ class TestProcessClassMethods: machine=self.machine, process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, - pid=99900 + i, - started_at=timezone.now(), + pid=os.getpid(), + started_at=_current_process_started_at(), ) count = Process.get_running_count(process_type=Process.TypeChoices.HOOK) @@ -868,10 +882,11 @@ class TestProcessClassMethods: def test_cleanup_stale_running(self): """cleanup_stale_running should mark stale processes as exited.""" + pid, _started_at = _reaped_process_identity(self.binary.abspath) stale = Process.objects.create( machine=self.machine, status=Process.StatusChoices.RUNNING, - pid=999999, + pid=pid, started_at=timezone.now() - PID_REUSE_WINDOW - timedelta(hours=1), ) @@ -883,11 +898,12 @@ class TestProcessClassMethods: def test_cleanup_stale_running_marks_timed_out_rows_exited(self): """cleanup_stale_running should retire RUNNING rows that exceed timeout + grace.""" + pid, _started_at = _reaped_process_identity(self.binary.abspath) stale = Process.objects.create( machine=self.machine, process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, - pid=999998, + pid=pid, timeout=5, started_at=timezone.now() - PROCESS_TIMEOUT_GRACE - timedelta(seconds=10), ) @@ -917,16 +933,13 @@ class TestProcessClassMethods: def test_cleanup_orphaned_workers_marks_dead_root_children_exited(self): """cleanup_orphaned_workers should retire rows whose CLI/orchestrator root is gone.""" - import psutil - from datetime import datetime - - started_at = datetime.fromtimestamp(psutil.Process(os.getpid()).create_time(), tz=timezone.get_current_timezone()) + parent_pid, parent_started_at = _reaped_process_identity(self.binary.abspath) parent = Process.objects.create( machine=self.machine, process_type=Process.TypeChoices.CLI, status=Process.StatusChoices.RUNNING, - pid=999997, - started_at=timezone.now() - timedelta(minutes=5), + pid=parent_pid, + started_at=parent_started_at, ) child = Process.objects.create( machine=self.machine, @@ -934,7 +947,7 @@ class TestProcessClassMethods: process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, pid=os.getpid(), - started_at=started_at, + started_at=_current_process_started_at(), ) cleaned = Process.cleanup_orphaned_workers() @@ -945,12 +958,13 @@ class TestProcessClassMethods: def test_cleanup_orphaned_workers_marks_non_running_children_exited(self): """cleanup_orphaned_workers should retire child rows whose OS process is already gone.""" + pid, started_at = _reaped_process_identity(self.binary.abspath) child = Process.objects.create( machine=self.machine, process_type=Process.TypeChoices.HOOK, status=Process.StatusChoices.RUNNING, - pid=999997, - started_at=timezone.now() - timedelta(minutes=5), + pid=pid, + started_at=started_at, ) cleaned = Process.cleanup_orphaned_workers() diff --git a/archivebox/tests/test_migrations_08_to_09.py b/archivebox/tests/test_migrations_08_to_09.py index 9d156bda..f5e0753a 100644 --- a/archivebox/tests/test_migrations_08_to_09.py +++ b/archivebox/tests/test_migrations_08_to_09.py @@ -316,36 +316,6 @@ def test_hyphenated_crawl_ids_are_normalized_before_snapshot_saves(migration_08_ assert "FOREIGN KEY constraint failed" not in output -def test_migration_removes_seed_id_column(migration_08_data): - """Migration should remove seed_id column from archivebox.crawls.crawl.""" - work_dir, db_path, original_data = migration_08_data - result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45) - assert result.returncode == 0, f"Init failed: {result.stderr}" - - conn = sqlite3.connect(str(db_path)) - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(crawls_crawl)") - columns = [row[1] for row in cursor.fetchall()] - conn.close() - - assert "seed_id" not in columns, f"seed_id column should have been removed by migration. Columns: {columns}" - - -def test_migration_removes_seed_table(migration_08_data): - """Migration should remove crawls_seed table.""" - work_dir, db_path, original_data = migration_08_data - result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45) - assert result.returncode == 0, f"Init failed: {result.stderr}" - - conn = sqlite3.connect(str(db_path)) - cursor = conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='crawls_seed'") - table_exists = cursor.fetchone() is not None - conn.close() - - assert not table_exists, "crawls_seed table should have been removed by migration" - - def test_add_works_after_migration(migration_08_data): """Adding new URLs should work after migration from 0.8.x.""" work_dir, db_path, original_data = migration_08_data diff --git a/archivebox/tests/test_migrations_fresh.py b/archivebox/tests/test_migrations_fresh.py index d3cacdda..cfdddfb7 100644 --- a/archivebox/tests/test_migrations_fresh.py +++ b/archivebox/tests/test_migrations_fresh.py @@ -144,9 +144,6 @@ def test_crawl_table_has_required_columns(tmp_path): for col in required: assert col in columns, f"Missing column: {col}" - # seed_id should NOT exist (removed in 0.9.x) - assert "seed_id" not in columns, "seed_id column should not exist in 0.9.x" - def test_add_urls_separately(tmp_path): """Should be able to add multiple URLs one at a time.""" diff --git a/archivebox/tests/test_misc_checks.py b/archivebox/tests/test_misc_checks.py index ecf7e47c..f44aad00 100644 --- a/archivebox/tests/test_misc_checks.py +++ b/archivebox/tests/test_misc_checks.py @@ -1,12 +1,14 @@ import os import signal +import subprocess +import sys +import textwrap import pytest from archivebox.core.shutdown_util import foreground_shutdown_signals from archivebox.core.shutdown_util import raise_if_shutdown_requested from archivebox.misc.checks import _migration_interrupt_message -from archivebox.misc.checks import _exit_on_migration_interrupt def test_migration_interrupt_message_prints_resume_command_and_atomic_safety(): @@ -25,23 +27,37 @@ def test_migration_interrupt_message_before_apply_says_no_changes_applied(): assert "archivebox init" in message -def test_migration_interrupt_handler_exits_for_sigint_and_sigterm(monkeypatch): - def fake_exit(code): - raise SystemExit(code) +@pytest.mark.parametrize("sig", [signal.SIGINT, signal.SIGTERM]) +def test_migration_interrupt_handler_exits_for_sigint_and_sigterm(sig): + process = subprocess.Popen( + [ + sys.executable, + "-c", + textwrap.dedent( + """ + import signal - monkeypatch.setattr("archivebox.misc.checks.os._exit", fake_exit) + from archivebox.misc.checks import _exit_on_migration_interrupt - for sig in (signal.SIGINT, signal.SIGTERM): - previous_handler = signal.getsignal(sig) - try: - with _exit_on_migration_interrupt(): - assert signal.getsignal(sig) != previous_handler - os.kill(os.getpid(), sig) - except SystemExit as err: - assert err.code == 130 - else: - raise AssertionError(f"{sig.name} should exit during migration auto-apply") - assert signal.getsignal(sig) == previous_handler + with _exit_on_migration_interrupt(): + print("READY", flush=True) + signal.pause() + """, + ), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=os.environ.copy(), + ) + assert process.stdout is not None + assert process.stdout.readline() == "READY\n" + + process.send_signal(sig) + stdout, stderr = process.communicate(timeout=30) + + assert process.returncode == 130, (stdout, stderr) def test_nested_foreground_signal_state_propagates_to_outer_context(): diff --git a/archivebox/tests/test_opencode_agent.py b/archivebox/tests/test_opencode_agent.py index 8fcdcc14..2876841f 100644 --- a/archivebox/tests/test_opencode_agent.py +++ b/archivebox/tests/test_opencode_agent.py @@ -52,8 +52,6 @@ def opencode_archive_config(initialized_archive): env = os.environ.copy() env.update( { - "ABXPKG_INSTALL_TIMEOUT": "900", - "ABXPKG_MIN_RELEASE_AGE": "0", "ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS": "true", "OPENCODE_ENABLED": "True", "OPENCODE_HOST": "127.0.0.1", @@ -113,10 +111,7 @@ def live_opencode(opencode_archive_config): finally: if views._PROCESS and views._PROCESS.poll() is None: views._PROCESS.terminate() - try: - views._PROCESS.wait(timeout=10) - except Exception: - views._PROCESS.kill() + views._PROCESS.wait(timeout=10) views._PROCESS = None diff --git a/archivebox/tests/test_process_runtime_paths.py b/archivebox/tests/test_process_runtime_paths.py index c5b51a85..d3f3e22f 100644 --- a/archivebox/tests/test_process_runtime_paths.py +++ b/archivebox/tests/test_process_runtime_paths.py @@ -1,34 +1,56 @@ -import os +from importlib.resources import files from pathlib import Path +import pytest -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "archivebox.settings") +from archivebox.tests.conftest import run_archivebox_cmd +from archivebox.tests.test_orm_helpers import use_archivebox_db - -from archivebox.machine.models import Process +pytestmark = pytest.mark.django_db(transaction=True) class TestProcessRuntimePaths: - def test_hook_processes_use_isolated_runtime_dir(self): - process = Process( - process_type=Process.TypeChoices.HOOK, - pwd="/tmp/archive/example/chrome", - cmd=["node", "/plugins/chrome/on_Snapshot__11_chrome_wait.js", "--url=https://example.com"], - ) + def test_hook_processes_use_isolated_runtime_dir(self, tmp_path): + from archivebox.plugins.hooks import run_hook - expected_dir = Path("/tmp/archive/example/chrome/.hooks/on_Snapshot__11_chrome_wait.js") + snap_dir = tmp_path / "snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real runtime path input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + process = run_hook( + hook_path, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="https://example.com/runtime-path", + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + + expected_dir = output_dir / ".hooks" / hook_path.name assert process.runtime_dir == expected_dir assert process.stdout_file == expected_dir / "stdout.log" assert process.stderr_file == expected_dir / "stderr.log" - def test_non_hook_processes_keep_runtime_files_in_pwd(self): - process = Process( - process_type=Process.TypeChoices.WORKER, - pwd="/tmp/archive/example", - cmd=["archivebox", "run", "--snapshot-id", "123"], + def test_non_hook_processes_keep_runtime_files_in_pwd(self, tmp_path): + init_result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, timeout=90) + assert init_result.returncode == 0, init_result.stderr or init_result.stdout + add_result = run_archivebox_cmd( + ["add", "--index-only", "--depth=0", "https://example.com/runtime-path"], + cwd=tmp_path, + timeout=90, ) + assert add_result.returncode == 0, add_result.stderr or add_result.stdout - expected_dir = Path("/tmp/archive/example") + with use_archivebox_db(tmp_path): + from archivebox.machine.models import Process + + process = next( + row for row in Process.objects.order_by("-created_at") if "add" in row.cmd and row.process_type != Process.TypeChoices.HOOK + ) + + expected_dir = Path(process.pwd) assert process.runtime_dir == expected_dir assert process.stdout_file == expected_dir / "stdout.log" assert process.stderr_file == expected_dir / "stderr.log" diff --git a/archivebox/tests/test_process_service.py b/archivebox/tests/test_process_service.py index 2c1484aa..f399e71d 100644 --- a/archivebox/tests/test_process_service.py +++ b/archivebox/tests/test_process_service.py @@ -5,60 +5,36 @@ pytestmark = pytest.mark.django_db @pytest.mark.django_db(transaction=True) -def test_process_completed_persists_with_uncached_network_interface(tmp_path): +def test_process_completed_persists_with_uncached_network_interface(tmp_path, recursive_test_site): import asyncio - from abx_dl.events import CrawlCleanupEvent, ProcessCompletedEvent - from abx_dl.orchestrator import create_bus + import archivebox.machine.models as machine_models + from archivebox.base_models.models import get_or_create_system_user_pk + from archivebox.crawls.models import Crawl from archivebox.machine.models import Machine, NetworkInterface, Process - from archivebox.services.process_service import ProcessService + from archivebox.services.runner import CrawlRunner machine = Machine.current() iface = NetworkInterface.current() + crawl = Crawl.objects.create( + urls=recursive_test_site["root_url"], + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "PLUGINS": "headers"}, + created_by_id=get_or_create_system_user_pk(), + ) - output_dir = tmp_path / "headers" - output_dir.mkdir() - bus = create_bus(name="test_process_completed_uncached_iface") - ProcessService(bus) + machine_models._CURRENT_INTERFACE = None + runner = CrawlRunner(crawl, selected_plugins=["headers"], show_progress=False) + asyncio.run(runner.run()) - async def run_event() -> None: - event = bus.emit( - ProcessCompletedEvent( - plugin_name="headers", - hook_name="on_Snapshot__27_headers.daemon.bg", - hook_path="/bin/echo", - hook_args=["--url=https://example.com"], - is_background=True, - output_dir=str(output_dir), - env={}, - timeout=60, - pid=123, - stdout="", - stderr="", - exit_code=0, - status="succeeded", - output_files=[], - start_ts="2026-05-13T07:22:00+00:00", - end_ts="2026-05-13T07:22:01+00:00", - ), - ) - await event.now() - await event.wait() - await event.event_results_list() - cleanup = bus.emit( - CrawlCleanupEvent( - url="https://example.com", - snapshot_id="test-snapshot", - output_dir=str(output_dir), - ), - ) - await cleanup.now() - await cleanup.wait() - - asyncio.run(run_event()) - - process = Process.objects.get(pwd=str(output_dir), cmd=["/bin/echo", "--url=https://example.com"]) + process = next( + process + for process in Process.objects.filter(process_type=Process.TypeChoices.HOOK) + if process.cmd and "on_Snapshot__27_headers.daemon.bg.js" in str(process.cmd[0]) + ) assert process.machine_id == machine.id assert process.iface_id == iface.id assert process.process_type == Process.TypeChoices.HOOK assert process.status == Process.StatusChoices.EXITED + assert process.started_at is not None + assert process.ended_at is not None + assert process.pid is not None diff --git a/archivebox/tests/test_recursive_crawl.py b/archivebox/tests/test_recursive_crawl.py index 23bc6a17..6dc00df2 100644 --- a/archivebox/tests/test_recursive_crawl.py +++ b/archivebox/tests/test_recursive_crawl.py @@ -3,8 +3,6 @@ import json import os -import subprocess -import time from pathlib import Path import pytest @@ -18,40 +16,18 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db pytestmark = pytest.mark.django_db(transaction=True) -def wait_for_db_condition(timeout, condition, interval=0.5): - deadline = time.time() + timeout - while time.time() < deadline: - if os.path.exists("index.sqlite3"): - with use_archivebox_db("."): - if condition(): - return True - time.sleep(interval) - return False - - -def stop_process(proc): - if proc.poll() is None: - proc.terminate() - try: - return proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - return proc.communicate() - - def run_add_until(args, env, condition, timeout=120): assert args[0] == "archivebox" - proc = run_archivebox_cmd( + result = run_archivebox_cmd( args[1:], cwd=Path.cwd(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, env=env, - wait=False, + timeout=timeout, ) - - assert wait_for_db_condition(timeout=timeout, condition=condition), f"Timed out waiting for condition while running: {' '.join(args)}" - return stop_process(proc) + assert result.returncode == 0, result.stderr or result.stdout + with use_archivebox_db("."): + assert condition(), f"Condition was false after command completed: {' '.join(args)}" + return result.stdout, result.stderr def test_background_hooks_dont_block_parser_extractors(tmp_path, initialized_archive, recursive_test_site): @@ -83,24 +59,16 @@ def test_background_hooks_dont_block_parser_extractors(tmp_path, initialized_arc }, ) - proc = run_archivebox_cmd( - ["add", "--depth=1", "--plugins=favicon,parse_html_urls", recursive_test_site["root_url"]], - cwd=tmp_path, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout, stderr = run_add_until( + ["archivebox", "add", "--depth=1", "--plugins=favicon,parse_html_urls", recursive_test_site["root_url"]], env=env, - wait=False, - ) - - assert wait_for_db_condition( timeout=120, condition=lambda: ArchiveResult.objects.filter( plugin__startswith="parse_", plugin__endswith="_urls", status__in=("started", "succeeded", "failed"), ).exists(), - ), "Parser extractors never progressed beyond queued status" - stdout, stderr = stop_process(proc) + ) if stderr: print(f"\n=== STDERR ===\n{stderr}\n=== END STDERR ===\n") @@ -426,7 +394,6 @@ def test_add_archivewebpage_installs_required_chrome_dependency(initialized_arch "USE_COLOR": "false", "SHOW_PROGRESS": "false", "TIMEOUT": "120", - "ABXPKG_INSTALL_TIMEOUT": "900", "ABXPKG_LIB_DIR": str(initialized_archive / "lib"), "CHROME_HEADLESS": "true", "CHROME_SANDBOX": "false", @@ -550,7 +517,6 @@ def test_recursive_crawl_depth_two_all_plugins_runs_snapshots_in_parallel( "ABXPKG_LIB_DIR": str(initialized_archive / "lib"), "CHROMEWEBSTORE_EXTENSIONS_DIR": str(initialized_archive / "lib/chromewebstore/extensions"), "TIMEOUT": "90", - "ABXPKG_INSTALL_TIMEOUT": "900", "CRAWL_MAX_CONCURRENT_SNAPSHOTS": "3", "SEARCH_BACKEND_SONIC_HOST_NAME": "127.0.0.1", "SEARCH_BACKEND_SONIC_PORT": str(free_tcp_port_factory()), diff --git a/archivebox/tests/test_search.py b/archivebox/tests/test_search.py index 661f1f1c..5091e041 100644 --- a/archivebox/tests/test_search.py +++ b/archivebox/tests/test_search.py @@ -1,4 +1,3 @@ -import json import os import re import time @@ -13,10 +12,10 @@ import requests from asgiref.sync import async_to_sync from django.contrib.auth import get_user_model from django.core.cache import cache -from django.test import override_settings from django.urls import reverse from archivebox.misc.logging import AttrDict +from archivebox.machine.models import Machine from archivebox.tests.conftest import ( cli_env, create_admin_and_token, @@ -25,7 +24,7 @@ from archivebox.tests.conftest import ( resolve_abxpkg_binary_env, start_archivebox_server, stop_archivebox_process, - wait_for_http, + get_http_response, ) @@ -131,10 +130,10 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path): os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] = old_env -def test_search_mode_options_use_canonical_backend_names(monkeypatch): +def test_search_mode_options_use_canonical_backend_names(): from archivebox.search.config import get_search_mode_options - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) options = get_search_mode_options() @@ -178,8 +177,8 @@ def test_snapshot_metadata_search_includes_notes_crawl_fields_username_and_confi class TestAdminSnapshotSearch: - def test_admin_search_mode_selector_defaults_to_configured_deep_backend_for_ripgrep(self, client, admin_user, monkeypatch): - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + def test_admin_search_mode_selector_defaults_to_configured_deep_backend_for_ripgrep(self, client, admin_user): + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) client.login(username="testadmin", password="testpassword") response = client.get(reverse("admin:core_snapshot_changelist"), HTTP_HOST=ADMIN_HOST) @@ -192,8 +191,8 @@ class TestAdminSnapshotSearch: assert b'value="deep:ripgrep"' in response.content assert b">deep:ripgrep<" in response.content - def test_admin_search_mode_selector_defaults_to_configured_deep_backend_for_sqlite(self, client, admin_user, monkeypatch): - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "sqlite") + def test_admin_search_mode_selector_defaults_to_configured_deep_backend_for_sqlite(self, client, admin_user): + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "sqlite"}}) client.login(username="testadmin", password="testpassword") response = client.get(reverse("admin:core_snapshot_changelist"), HTTP_HOST=ADMIN_HOST) @@ -222,10 +221,10 @@ class TestAdminSnapshotSearch: assert b'id="changelist"' in response.content assert b"search-mode-contents" in response.content - def test_admin_search_stream_uses_real_ripgrep_backend_for_deep_results(self, client, admin_user, crawl, monkeypatch): + def test_admin_search_stream_uses_real_ripgrep_backend_for_deep_results(self, client, admin_user, crawl): from archivebox.core.models import Snapshot - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) fulltext_snapshot = Snapshot.objects.create( url="https://example.com/fulltext-only", title="Unrelated Title", @@ -279,10 +278,10 @@ class TestAdminSnapshotSearch: assert result_ids[:3] == [prefix_snapshot.pk, title_snapshot.pk, contains_snapshot.pk] assert {title_snapshot.pk, contains_snapshot.pk, prefix_snapshot.pk}.issubset(result_ids) - def test_admin_contents_search_stream_uses_real_backend_results(self, client, admin_user, crawl, monkeypatch): + def test_admin_contents_search_stream_uses_real_backend_results(self, client, admin_user, crawl): from archivebox.core.models import Snapshot - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) metadata_snapshot = Snapshot.objects.create( url="https://example.com/google-meta", title="Google Metadata Match", @@ -382,7 +381,10 @@ class TestAdminSnapshotSearch: class TestPublicIndexSearch: - @override_settings(PUBLIC_INDEX=True) + @pytest.fixture(autouse=True) + def public_index_enabled(self): + Machine.from_json({"config": {"PUBLIC_INDEX": True}}) + def test_public_search_by_url(self, client, public_snapshot): cache.clear() response = client.get("/public/", {"q": "public-example.com"}, HTTP_HOST=WEB_HOST) @@ -391,9 +393,8 @@ class TestPublicIndexSearch: assert b"matching snapshots..." in response.content assert b"No snapshots found." not in response.content - @override_settings(PUBLIC_INDEX=True) - def test_public_search_mode_selector_defaults_to_configured_deep_backend_for_ripgrep(self, client, monkeypatch): - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + def test_public_search_mode_selector_defaults_to_configured_deep_backend_for_ripgrep(self, client): + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) response = client.get("/public/", HTTP_HOST=WEB_HOST) @@ -405,11 +406,10 @@ class TestPublicIndexSearch: assert b'value="deep:ripgrep"' in response.content assert b">deep:ripgrep<" in response.content - @override_settings(PUBLIC_INDEX=True) - def test_public_search_uses_streamed_metadata_order(self, client, crawl, monkeypatch): + def test_public_search_uses_streamed_metadata_order(self, client, crawl): from archivebox.core.models import Snapshot - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "ripgrep") + Machine.from_json({"config": {"SEARCH_BACKEND_ENGINE": "ripgrep"}}) metadata_snapshot = Snapshot.objects.create( url="https://public-example.com/google-meta", title="Google Metadata Match", @@ -441,7 +441,6 @@ class TestPublicIndexSearch: content = response.content.decode() assert content.index(str(metadata_snapshot.url)) < content.index(str(fulltext_snapshot.url)) - @override_settings(PUBLIC_INDEX=True) def test_public_metadata_search_prioritizes_common_url_prefixes(self, client, crawl): from archivebox.core.models import Snapshot @@ -473,14 +472,12 @@ class TestPublicIndexSearch: assert content.index(str(prefix_match.url)) < content.index(str(broad_match.url)) - @override_settings(PUBLIC_INDEX=True) def test_public_search_by_title(self, client, public_snapshot): response = client.get("/public/", {"q": "Public Example"}, HTTP_HOST=WEB_HOST) assert response.status_code == 200 assert b"archivebox-search-stream-status" in response.content - @override_settings(PUBLIC_INDEX=True) def test_public_search_stream_preserves_search_form_dom(self, client, public_snapshot): response = client.get("/public/", {"q": "Public Example"}, HTTP_HOST=WEB_HOST) @@ -489,7 +486,6 @@ class TestPublicIndexSearch: assert b"replaceRegionFromDocument(doc, '#table-bookmarks tbody')" in response.content assert b"currentList.replaceWith(nextList)" not in response.content - @override_settings(PUBLIC_INDEX=True) def test_public_search_stream_populates_public_results_cache(self, client, public_snapshot): search_params = {"q": "Public Example", "search_mode": "meta"} search_url = f"/public/?{urlencode(search_params)}" @@ -510,7 +506,6 @@ class TestPublicIndexSearch: assert b"Public Example Website" in response.content assert b"No snapshots found." not in response.content - @override_settings(PUBLIC_INDEX=True) def test_public_index_shows_exact_total_count_and_page_count_for_100_plus_snapshots(self, client, crawl, public_snapshot): from archivebox.core.models import Snapshot @@ -572,7 +567,6 @@ class TestPublicIndexSearch: assert "last »" not in last_content assert "private-page-test" not in last_content - @override_settings(PUBLIC_INDEX=True) def test_public_index_preview_respects_root_relative_screenshot_output(self, client, public_snapshot): from archivebox.core.models import ArchiveResult @@ -593,7 +587,6 @@ class TestPublicIndexSearch: assert "/screenshot.png" in content assert "/screenshot/screenshot.png" not in content - @override_settings(PUBLIC_INDEX=True) def test_public_index_preview_respects_plugin_relative_screenshot_output(self, client, public_snapshot): from archivebox.core.models import ArchiveResult @@ -613,7 +606,6 @@ class TestPublicIndexSearch: content = response.content.decode() assert "screenshot/screenshot.png" in content - @override_settings(PUBLIC_INDEX=True) def test_public_index_preview_falls_back_to_extension_screenshots(self, client, public_snapshot): from archivebox.core.models import ArchiveResult @@ -638,7 +630,6 @@ class TestPublicIndexSearch: assert "/screenshot/screenshot.png" not in content assert "chrome_extension_screenshot/screenshot-2.png" not in content - @override_settings(PUBLIC_INDEX=True) def test_public_index_snapshot_without_preview_renders_placeholder(self, client, public_snapshot): response = client.get("/public/", HTTP_HOST=WEB_HOST) @@ -647,7 +638,6 @@ class TestPublicIndexSearch: assert "snapshot-preview-empty" in content assert "screenshot/screenshot.png" not in content - @override_settings(PUBLIC_INDEX=True) def test_public_index_pending_snapshot_uses_small_preview_spinner(self, client, crawl): from archivebox.core.models import Snapshot @@ -665,7 +655,6 @@ class TestPublicIndexSearch: assert "snapshot-preview-spinner" in content assert "spinner.gif" in content - @override_settings(PUBLIC_INDEX=True) def test_public_index_finished_snapshot_without_title_falls_back_to_url(self, client, public_snapshot): public_snapshot.title = "" public_snapshot.save(update_fields=["title"]) @@ -677,19 +666,16 @@ class TestPublicIndexSearch: assert "https://public-example.com" in content assert "Loading..." not in content - @override_settings(PUBLIC_INDEX=True) def test_public_search_query_type_meta(self, client, public_snapshot): response = client.get("/public/", {"q": "example", "query_type": "meta"}, HTTP_HOST=WEB_HOST) assert response.status_code == 200 - @override_settings(PUBLIC_INDEX=True) def test_public_search_query_type_url(self, client, public_snapshot): response = client.get("/public/", {"q": "public-example.com", "query_type": "url"}, HTTP_HOST=WEB_HOST) assert response.status_code == 200 - @override_settings(PUBLIC_INDEX=True) def test_public_search_query_type_title(self, client, public_snapshot): response = client.get("/public/", {"q": "Website", "query_type": "title"}, HTTP_HOST=WEB_HOST) @@ -784,16 +770,15 @@ class TestSearchBackendsE2E: if body is None: self.send_response(404) self.end_headers() - return - - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) + else: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) def log_message(self, _format, *args): - return + pass fixture_server = ThreadingHTTPServer(("127.0.0.1", 0), SearchMatrixHandler) fixture_thread = Thread(target=fixture_server.serve_forever, daemon=True) @@ -885,70 +870,36 @@ class TestSearchBackendsE2E: ) assert second_add_result.returncode == 0, second_add_result.stderr or second_add_result.stdout - metadata_snapshot_records = [ - { - "type": "Snapshot", - "url": url_only_url, - "title": "URL Only Precision Page", - "tags": "search-matrix", - "depth": 0, - }, - { - "type": "Snapshot", - "url": title_only_url, - "title": title_only_needle, - "tags": "search-matrix", - "depth": 0, - }, - { - "type": "Snapshot", - "url": tag_only_url, - "title": "Tag Only Precision Page", - "tags": f"search-matrix,{tag_only_needle}", - "depth": 0, - }, - { - "type": "Snapshot", - "url": title_prefix_order_url, - "title": title_prefix_order_title, - "tags": "search-matrix", - "depth": 0, - }, - { - "type": "Snapshot", - "url": url_contains_order_url, - "title": "URL Contains Ordering Page", - "tags": "search-matrix", - "depth": 0, - }, - { - "type": "Snapshot", - "url": title_contains_order_url, - "title": title_contains_order_title, - "tags": "search-matrix", - "depth": 0, - }, - { - "type": "Snapshot", - "url": tag_order_url, - "title": "Tag Ordering Page", - "tags": f"search-matrix,{order_needle}", - "depth": 0, - }, + metadata_snapshots = [ + (url_only_url, "URL Only Precision Page", "search-matrix"), + (title_only_url, title_only_needle, "search-matrix"), + (tag_only_url, "Tag Only Precision Page", f"search-matrix,{tag_only_needle}"), + (title_prefix_order_url, title_prefix_order_title, "search-matrix"), + (url_contains_order_url, "URL Contains Ordering Page", "search-matrix"), + (title_contains_order_url, title_contains_order_title, "search-matrix"), + (tag_order_url, "Tag Ordering Page", f"search-matrix,{order_needle}"), ] - metadata_create_result = run_archivebox_cmd( - ["snapshot", "create"], - cwd=initialized_archive, - env=env, - input="\n".join(json.dumps(record) for record in metadata_snapshot_records) + "\n", - timeout=60, - ) - assert metadata_create_result.returncode == 0, metadata_create_result.stderr or metadata_create_result.stdout + metadata_create_outputs = [] + for url, _title, tags in metadata_snapshots: + create_result = run_archivebox_cmd( + ["snapshot", "create", f"--tag={tags}", url], + cwd=initialized_archive, + env=env, + timeout=60, + ) + assert create_result.returncode == 0, create_result.stderr or create_result.stdout + metadata_create_outputs.append(create_result.stdout) + from archivebox.core.models import Snapshot + from archivebox.tests.test_orm_helpers import use_archivebox_db + + with use_archivebox_db(initialized_archive): + for url, title, _tags in metadata_snapshots: + Snapshot.objects.filter(url=url).update(title=title) metadata_seal_result = run_archivebox_cmd( ["snapshot", "update", "--status=sealed"], cwd=initialized_archive, env=env, - input=metadata_create_result.stdout, + input="".join(metadata_create_outputs), timeout=60, ) assert metadata_seal_result.returncode == 0, metadata_seal_result.stderr or metadata_seal_result.stdout @@ -978,13 +929,13 @@ class TestSearchBackendsE2E: log_name="search-matrix-server.log", env=env, ) - wait_for_http( + get_http_response( archivebox_port, host=f"web.archivebox.localhost:{archivebox_port}", path="/public/", process=archivebox_server, ) - wait_for_http( + get_http_response( archivebox_port, host=f"admin.archivebox.localhost:{archivebox_port}", path="/admin/login/", diff --git a/archivebox/tests/test_server_security_browser.py b/archivebox/tests/test_server_security_browser.py index 4ce86eda..3b907fb8 100644 --- a/archivebox/tests/test_server_security_browser.py +++ b/archivebox/tests/test_server_security_browser.py @@ -7,7 +7,6 @@ import json import os import subprocess import textwrap -import time from pathlib import Path from urllib.parse import urlencode @@ -21,7 +20,8 @@ from .conftest import ( start_archivebox_server as start_daemon_server, stop_archivebox_process, stop_server as stop_daemon_server, - wait_for_http, + get_http_response, + wait_for_log, ) @@ -169,39 +169,34 @@ async function frameText(frame) { } } -async function findPreviewText(page, expectedText, timeoutMs) { - const deadline = Date.now() + timeoutMs; - let lastState = []; - while (Date.now() < deadline) { - const frames = page.frames(); - const previewFrame = frames.find((frame) => { - const url = frame.url(); - return url.includes("/archivewebpage/archivewebpage.wacz") && url.includes("preview=1"); - }); +async function readPreviewText(page, expectedText) { + const frames = page.frames(); + const previewFrame = frames.find((frame) => { + const url = frame.url(); + return url.includes("/archivewebpage/archivewebpage.wacz") && url.includes("preview=1"); + }); - lastState = []; - for (const frame of frames) { - const text = await frameText(frame); - lastState.push({ - name: frame.name(), - url: frame.url(), - isPreview: frame === previewFrame, - underPreview: previewFrame ? isDescendantOf(frame, previewFrame) : false, - textSample: text.slice(0, 240), - }); - if (previewFrame && (frame === previewFrame || isDescendantOf(frame, previewFrame)) && text.includes(expectedText)) { - return { - matched: true, - previewUrl: previewFrame.url(), - matchedFrameUrl: frame.url(), - matchedFrameName: frame.name(), - textSample: text.slice(0, 400), - }; - } + const frameState = []; + for (const frame of frames) { + const text = await frameText(frame); + frameState.push({ + name: frame.name(), + url: frame.url(), + isPreview: frame === previewFrame, + underPreview: previewFrame ? isDescendantOf(frame, previewFrame) : false, + textSample: text.slice(0, 240), + }); + if (previewFrame && (frame === previewFrame || isDescendantOf(frame, previewFrame)) && text.includes(expectedText)) { + return { + matched: true, + previewUrl: previewFrame.url(), + matchedFrameUrl: frame.url(), + matchedFrameName: frame.name(), + textSample: text.slice(0, 400), + }; } - await new Promise((resolve) => setTimeout(resolve, 500)); } - return {matched: false, frames: lastState}; + return {matched: false, frames: frameState}; } async function main() { @@ -236,7 +231,8 @@ async function main() { waitUntil: "domcontentloaded", timeout: 30000, }); - const previewResult = await findPreviewText(page, config.expectedText, 60000); + await page.waitForNetworkIdle({idleTime: 500, timeout: 60000}); + const previewResult = await readPreviewText(page, config.expectedText); console.log(JSON.stringify({ detailUrl: config.detailUrl, @@ -260,8 +256,6 @@ main().catch((error) => { def browser_runtime(initialized_archive: Path): shared_lib = initialized_archive / "lib" env = cli_env( - ABXPKG_INSTALL_TIMEOUT="900", - ABXPKG_MIN_RELEASE_AGE="0", ABXPKG_LIB_DIR=str(shared_lib), CHROME_HEADLESS="True", CHROME_SANDBOX="False", @@ -484,19 +478,23 @@ def _run_browser_probe( "USE_CHROME": "False", }, ) - process = run_archivebox_cmd( - ["server", "--debug", "--nothreading", f"127.0.0.1:{port}"], - cwd=data_dir, - env=server_env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - start_new_session=True, - wait=False, - ) + server_log_path = tmp_path / f"{mode}_server.log" + with server_log_path.open("w", encoding="utf-8") as server_log_file: + process = run_archivebox_cmd( + ["server", "--debug", "--nothreading", f"127.0.0.1:{port}"], + cwd=data_dir, + env=server_env, + stdout=server_log_file, + stderr=subprocess.STDOUT, + start_new_session=True, + wait=False, + ) try: - wait_for_http(port, f"archivebox.localhost:{port}", process=process) + wait_for_log(server_log_path, "Listening on TCP", timeout=30) + get_http_response(port, f"archivebox.localhost:{port}", process=process) except AssertionError as exc: - server_log = stop_archivebox_process(process) + stop_archivebox_process(process) + server_log = server_log_path.read_text(encoding="utf-8", errors="replace") raise AssertionError(f"{exc}\n\nSERVER LOG:\n{server_log}") from exc probe_path = tmp_path / "server_security_probe.js" @@ -520,50 +518,32 @@ def _run_browser_probe( timeout=120, ) finally: - server_log = stop_archivebox_process(process) + stop_archivebox_process(process) + server_log = server_log_path.read_text(encoding="utf-8", errors="replace") assert result.returncode == 0, f"{result.stderr}\n\nSERVER LOG:\n{server_log}" return json.loads(result.stdout.strip()) -def _wait_for_archivewebpage_capture(data_dir: Path, url: str, timeout: float = 300.0) -> dict[str, str]: +def _get_archivewebpage_capture(data_dir: Path, url: str) -> dict[str, str]: from archivebox.core.models import ArchiveResult, Snapshot from archivebox.tests.test_orm_helpers import use_archivebox_db - deadline = time.time() + timeout - last_state = {} - while time.time() < deadline: - with use_archivebox_db(data_dir): - snapshot = Snapshot.objects.filter(url=url).order_by("-created_at").first() - if snapshot is None: - last_state = {"snapshot": "missing"} - else: - result = ( - ArchiveResult.objects.filter(snapshot=snapshot, plugin="archivewebpage") - .order_by("-created_at") - .values("status", "output_files", "output_str") - .first() - ) - wacz_path = Path(snapshot.output_dir) / "archivewebpage" / "archivewebpage.wacz" - last_state = { - "snapshot_id": str(snapshot.id), - "snapshot_status": str(snapshot.status), - "result": str(result), - "wacz_path": str(wacz_path), - "wacz_exists": str(wacz_path.is_file()), - } - if ( - snapshot.status == Snapshot.StatusChoices.SEALED - and result is not None - and result["status"] == ArchiveResult.StatusChoices.SUCCEEDED - and wacz_path.is_file() - ): - return { - "snapshot_id": str(snapshot.id), - "wacz_path": str(wacz_path), - } - time.sleep(2) - raise AssertionError(f"timed out waiting for archivewebpage capture: {last_state}") + with use_archivebox_db(data_dir): + snapshot = Snapshot.objects.get(url=url) + result = ArchiveResult.objects.get( + snapshot=snapshot, + plugin="archivewebpage", + hook_name="on_Snapshot__65_archivewebpage_stop", + ) + wacz_path = Path(snapshot.output_dir) / "archivewebpage" / "archivewebpage.wacz" + assert snapshot.status == Snapshot.StatusChoices.SEALED + assert result.status == ArchiveResult.StatusChoices.SUCCEEDED + assert wacz_path.is_file() + return { + "snapshot_id": str(snapshot.id), + "wacz_path": str(wacz_path), + } def _run_wacz_preview_probe( @@ -700,31 +680,28 @@ def test_server_security_modes_in_chrome( assert probe_results["admin"]["ok"] is expected["admin_ok"] assert probe_results["api"]["ok"] is expected["api_ok"] assert any("CORS policy" in text for text in console_texts) - return - - if mode == "safe-onedomain-nojsreplay": + elif mode == "safe-onedomain-nojsreplay": assert probe_results == {} assert "Dangerous Replay Fixture" in page_state["bodyText"] assert any("Blocked script execution" in text for text in console_texts) - return + else: + assert probe_results["own"]["ok"] is True + assert probe_results["own"]["status"] == 200 + assert "ATTACKER_SECRET" in probe_results["own"]["sample"] + assert probe_results["victim"]["ok"] is expected["victim_ok"] + assert probe_results["victim"]["status"] == expected["victim_status"] + assert "VICTIM_SECRET" in probe_results["victim"]["sample"] + assert probe_results["admin"]["ok"] is expected["admin_ok"] + assert probe_results["admin"]["status"] == expected["admin_status"] + assert probe_results["api"]["ok"] is expected["api_ok"] + assert probe_results["api"]["status"] == expected["api_status"] - assert probe_results["own"]["ok"] is True - assert probe_results["own"]["status"] == 200 - assert "ATTACKER_SECRET" in probe_results["own"]["sample"] - assert probe_results["victim"]["ok"] is expected["victim_ok"] - assert probe_results["victim"]["status"] == expected["victim_status"] - assert "VICTIM_SECRET" in probe_results["victim"]["sample"] - assert probe_results["admin"]["ok"] is expected["admin_ok"] - assert probe_results["admin"]["status"] == expected["admin_status"] - assert probe_results["api"]["ok"] is expected["api_ok"] - assert probe_results["api"]["status"] == expected["api_status"] - - if mode == "unsafe-onedomain-noadmin": - assert "control plane disabled" in probe_results["admin"]["sample"].lower() - assert "control plane disabled" in probe_results["api"]["sample"].lower() - elif mode == "danger-onedomain-fullreplay": - assert "ArchiveBox" in probe_results["admin"]["sample"] - assert "swagger" in probe_results["api"]["sample"].lower() + if mode == "unsafe-onedomain-noadmin": + assert "control plane disabled" in probe_results["admin"]["sample"].lower() + assert "control plane disabled" in probe_results["api"]["sample"].lower() + elif mode == "danger-onedomain-fullreplay": + assert "ArchiveBox" in probe_results["admin"]["sample"] + assert "swagger" in probe_results["api"]["sample"].lower() @pytest.mark.django_db(transaction=True) @@ -770,17 +747,17 @@ def test_archivewebpage_wacz_preview_serves_real_capture_frame(initialized_archi ) start_daemon_server(initialized_archive, env=env, port=port) - wait_for_http(port, host=f"archivebox.localhost:{port}", path="/") + get_http_response(port, host=f"archivebox.localhost:{port}", path="/") _cmd_result = run_archivebox_cmd( - ["add", "--bg", "--depth=0", "--max-urls=1", "--plugins=archivewebpage", url], + ["add", "--depth=0", "--max-urls=1", "--plugins=archivewebpage", url], cwd=initialized_archive, env=env, timeout=120, ) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode - assert returncode == 0, f"archivebox add --bg failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + assert returncode == 0, f"archivebox add failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" - capture = _wait_for_archivewebpage_capture(initialized_archive, url, timeout=360) + capture = _get_archivewebpage_capture(initialized_archive, url) snapshot_host = f"{get_snapshot_subdomain(capture['snapshot_id'])}.archivebox.localhost:{port}" detail_url = f"http://{snapshot_host}/#archivewebpage/archivewebpage.wacz" result = _run_wacz_preview_probe(initialized_archive, browser_runtime, detail_url, tmp_path) diff --git a/archivebox/tests/test_shutdown_util.py b/archivebox/tests/test_shutdown_util.py index f881f750..c4e50143 100644 --- a/archivebox/tests/test_shutdown_util.py +++ b/archivebox/tests/test_shutdown_util.py @@ -1,76 +1,136 @@ +import os import signal - -import pytest - -from archivebox.cli import archivebox_run -from archivebox.core import shutdown_util +import subprocess +import sys +import textwrap -def test_foreground_shutdown_second_signal_exits_immediately(monkeypatch): - def fake_exit(code): - raise SystemExit(code) - - monkeypatch.setattr(shutdown_util.os, "_exit", fake_exit) - - with shutdown_util.foreground_shutdown_signals() as state: - handler = signal.getsignal(signal.SIGTERM) - - with pytest.raises(KeyboardInterrupt): - handler(signal.SIGTERM, None) - assert state.signal_name == "SIGTERM" - - with pytest.raises(SystemExit) as err: - handler(signal.SIGTERM, None) - assert err.value.code == 130 +def _start_signal_process(source: str) -> subprocess.Popen[str]: + process = subprocess.Popen( + [sys.executable, "-c", textwrap.dedent(source)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=os.environ.copy(), + ) + assert process.stdout is not None + ready_line = process.stdout.readline() + if ready_line != "READY\n": + assert process.stderr is not None + stderr = process.stderr.read() + process.wait(timeout=30) + raise AssertionError(f"signal subprocess exited before readiness: {ready_line!r}\n{stderr}") + return process -def test_foreground_shutdown_can_request_cooperative_shutdown_without_raising(monkeypatch): - def fake_exit(code): - raise SystemExit(code) - - seen = [] - monkeypatch.setattr(shutdown_util.os, "_exit", fake_exit) - - with shutdown_util.foreground_shutdown_signals( - first_signal_message=None, - on_signal=seen.append, - raise_on_first_signal=False, - ) as state: - handler = signal.getsignal(signal.SIGTERM) - - handler(signal.SIGTERM, None) - assert state.signal_name == "SIGTERM" - assert seen == [signal.SIGTERM] - - with pytest.raises(SystemExit) as err: - handler(signal.SIGTERM, None) - assert err.value.code == 130 +def _signal_and_collect(process: subprocess.Popen[str], sig: signal.Signals) -> tuple[str, str]: + process.send_signal(sig) + return process.communicate(timeout=30) -def test_daemon_runner_signal_exit_is_unexpected_for_supervisor(monkeypatch): - def fake_exit(code): - raise SystemExit(code) +def test_foreground_shutdown_second_signal_exits_immediately(): + process = _start_signal_process( + """ + import signal - monkeypatch.setattr(archivebox_run.os, "_exit", fake_exit) + from archivebox.core.shutdown_util import foreground_shutdown_signals - with pytest.raises(SystemExit) as err: - archivebox_run._exit_daemon_runner_on_signal(signal.SIGTERM) + with foreground_shutdown_signals(first_signal_message=None) as state: + print("READY", flush=True) + try: + signal.pause() + except KeyboardInterrupt: + print(f"FIRST:{state.signal_name}", flush=True) + signal.pause() + """, + ) - assert err.value.code == 143 + process.send_signal(signal.SIGTERM) + assert process.stdout is not None + assert process.stdout.readline() == "FIRST:SIGTERM\n" + stdout, stderr = _signal_and_collect(process, signal.SIGTERM) + + assert process.returncode == 130, (stdout, stderr) -def test_crawl_runner_daemon_signal_exits_before_async_cleanup(monkeypatch): - from archivebox.services import runner as runner_module - from archivebox.services.runner import CrawlRunner +def test_foreground_shutdown_can_request_cooperative_shutdown_without_raising(): + process = _start_signal_process( + """ + import signal - def fake_exit(code): - raise SystemExit(code) + from archivebox.core.shutdown_util import foreground_shutdown_signals - runner = object.__new__(CrawlRunner) - monkeypatch.setenv("ARCHIVEBOX_RUNNER_DAEMON", "1") - monkeypatch.setattr(runner_module.os, "_exit", fake_exit) + def on_signal(sig): + print(f"SIGNAL:{sig.name}", flush=True) - with pytest.raises(SystemExit) as err: - runner._request_abort_from_signal(signal.SIGTERM) + with foreground_shutdown_signals( + first_signal_message=None, + on_signal=on_signal, + raise_on_first_signal=False, + ): + print("READY", flush=True) + signal.pause() + signal.pause() + """, + ) - assert err.value.code == 143 + process.send_signal(signal.SIGTERM) + assert process.stdout is not None + assert process.stdout.readline() == "SIGNAL:SIGTERM\n" + stdout, stderr = _signal_and_collect(process, signal.SIGTERM) + + assert process.returncode == 130, (stdout, stderr) + + +def test_daemon_runner_signal_exit_is_unexpected_for_supervisor(): + process = _start_signal_process( + """ + import signal + + from archivebox.cli.archivebox_run import _exit_daemon_runner_on_signal + + signal.signal(signal.SIGTERM, lambda signum, _frame: _exit_daemon_runner_on_signal(signal.Signals(signum))) + print("READY", flush=True) + signal.pause() + """, + ) + + stdout, stderr = _signal_and_collect(process, signal.SIGTERM) + + assert process.returncode == 143, (stdout, stderr) + + +def test_crawl_runner_daemon_signal_exits_before_async_cleanup(): + process = _start_signal_process( + """ + import os + import signal + import uuid + + import django + + django.setup() + + from archivebox.crawls.models import Crawl + from archivebox.core.shutdown_util import foreground_shutdown_signals + from archivebox.services.runner import CrawlRunner + + runner = CrawlRunner( + Crawl(urls="https://example.com", created_by_id=uuid.uuid4()), + show_progress=False, + ) + os.environ["ARCHIVEBOX_RUNNER_DAEMON"] = "1" + with foreground_shutdown_signals( + first_signal_message=None, + on_signal=runner._request_abort_from_signal, + raise_on_first_signal=False, + ): + print("READY", flush=True) + signal.pause() + """, + ) + + stdout, stderr = _signal_and_collect(process, signal.SIGTERM) + + assert process.returncode == 143, (stdout, stderr) diff --git a/archivebox/tests/test_takeover_util.py b/archivebox/tests/test_takeover_util.py index cda17486..52104591 100644 --- a/archivebox/tests/test_takeover_util.py +++ b/archivebox/tests/test_takeover_util.py @@ -3,10 +3,10 @@ import os import json +import re import signal import subprocess import sys -import time from pathlib import Path import pytest @@ -25,7 +25,7 @@ from archivebox.tests.conftest import ( start_archivebox_server, stop_archivebox_process, supervisor_pid_from_log, - wait_for_http, + get_http_response, wait_for_log, wait_for_log_count, wait_for_log_pattern, @@ -39,13 +39,16 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db pytestmark = pytest.mark.django_db(transaction=True) -def test_pid_is_alive_treats_unreaped_zombie_as_exited(): - proc = subprocess.Popen([sys.executable, "-c", "pass"]) +def test_pid_is_alive_treats_unreaped_archivebox_cli_as_exited(tmp_path, initialized_archive): + proc = run_archivebox_cmd( + ["version"], + cwd=tmp_path, + default_cli_env=True, + disable_extractors=True, + wait=False, + ) try: - deadline = time.time() + 5 - while time.time() < deadline and psutil.Process(proc.pid).status() != psutil.STATUS_ZOMBIE: - time.sleep(0.01) - + os.waitid(os.P_PID, proc.pid, os.WEXITED | os.WNOWAIT) assert psutil.Process(proc.pid).status() == psutil.STATUS_ZOMBIE assert not pid_is_alive(proc.pid) finally: @@ -115,7 +118,7 @@ def test_behavior_update_index_only_keeps_server_http_and_search_visible(tmp_pat server = None try: server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-update.log", env=env) - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 update = run_archivebox_cmd( ["update", "--index-only", "--batch-size=1"], @@ -125,7 +128,7 @@ def test_behavior_update_index_only_keeps_server_http_and_search_visible(tmp_pat ) assert update.returncode == 0, update.stderr or update.stdout - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 search = run_archivebox_cmd( ["list", "--search=contents", "--csv=url", "Root"], @@ -137,7 +140,7 @@ def test_behavior_update_index_only_keeps_server_http_and_search_visible(tmp_pat assert search.returncode == 0, search.stderr or search.stdout assert root_url in search.stdout finally: - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -174,7 +177,7 @@ def test_behavior_update_yields_to_server_then_finishes_visible_indexing(tmp_pat wait_for_log(update_log, "[*] Reindexing", timeout=90) server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-takes-update.log", env=env) - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 wait_for_log(update_log, "A newer archivebox process took over the orchestrator, sonic", timeout=90) stop_archivebox_process(server, signal.SIGTERM) @@ -193,9 +196,9 @@ def test_behavior_update_yields_to_server_then_finishes_visible_indexing(tmp_pat assert search.returncode == 0, search.stderr or search.stdout assert root_url in search.stdout finally: - if update_proc is not None and update_proc.poll() is None: + if update_proc is not None: stop_archivebox_process(update_proc, signal.SIGTERM) - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -209,7 +212,7 @@ def test_behavior_foreground_add_keeps_existing_server_http_visible(tmp_path, in server = None try: server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-add.log", env=env) - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 add = run_archivebox_cmd( [ @@ -224,11 +227,11 @@ def test_behavior_foreground_add_keeps_existing_server_http_visible(tmp_path, in ) assert add.returncode == 0, add.stderr or add.stdout - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=120) assert "Root" in captured_text finally: - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -242,7 +245,7 @@ def test_behavior_background_add_returns_and_server_archives_visible_url(tmp_pat server = None try: server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-bg-add.log", env=env) - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 add = run_archivebox_cmd( [ @@ -259,11 +262,11 @@ def test_behavior_background_add_returns_and_server_archives_visible_url(tmp_pat assert add.returncode == 0, add.stderr or add.stdout assert "background runner will process" in add.stdout - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180) assert "Root" in captured_text finally: - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -277,14 +280,14 @@ def test_behavior_daemonized_server_restarts_cleanly_after_forced_stop(tmp_path, try: first = start_archivebox_server(tmp_path, port=port, env=env, daemonize=True) assert first.returncode == 0, first.stderr or first.stdout - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) second = start_archivebox_server(tmp_path, port=port, env=env, daemonize=True) assert second.returncode == 0, second.stderr or second.stdout - assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500 + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 finally: kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -303,7 +306,7 @@ def test_live_second_server_takes_over_existing_server_process(tmp_path, initial second = start_archivebox_server(tmp_path, port=port, log_name="server-second.log", env=env) second_log = second.log_path - assert first.poll() is None + assert pid_is_alive(first.pid) first_text = first_log.read_text(encoding="utf-8", errors="replace") second_text = second_log.read_text(encoding="utf-8", errors="replace") assert "A newer archivebox process took over the orchestrator, server" in first_text @@ -322,12 +325,12 @@ def test_live_second_server_takes_over_existing_server_process(tmp_path, initial stop_archivebox_process(second, signal.SIGTERM) second = None wait_for_log_count(first_log, "Other newer archivebox process", first_resumes + 1, timeout=35) - assert first.poll() is None + assert pid_is_alive(first.pid) finally: - if second is not None and second.poll() is None: + if second is not None: stop_archivebox_process(second, signal.SIGTERM) - if first is not None and first.poll() is None: - stop_archivebox_process(first, signal.SIGKILL) + if first is not None: + stop_archivebox_process(first, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -353,7 +356,7 @@ def test_live_update_index_only_does_not_take_over_server_runtime(tmp_path, init update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert update_returncode == 0, update_stderr or update_stdout - assert server.poll() is None + assert pid_is_alive(server.pid) assert pid_is_alive(supervisor_pid_before) assert pid_is_alive(daphne_pid_before) assert supervisor_pid_from_log(server_log) == supervisor_pid_before @@ -363,7 +366,7 @@ def test_live_update_index_only_does_not_take_over_server_runtime(tmp_path, init errors="replace", ) finally: - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -389,6 +392,9 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm daphne_pid_before = worker_pid_from_log(server_log, "worker_daphne") runner_pid_before = worker_pid_from_log(server_log, "worker_runner") sonic_pid_before = worker_pid_from_log(server_log, "worker_sonic") + supervisord_log = tmp_path / "logs" / "supervisord.log" + runner_spawn_text = "spawned: 'worker_runner' with pid" + runner_spawn_count = supervisord_log.read_text(encoding="utf-8", errors="replace").count(runner_spawn_text) _cmd_result = run_archivebox_cmd( ["update", "--index-only", "--batch-size=1"], @@ -399,7 +405,7 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert update_returncode == 0, update_stderr or update_stdout - assert server.poll() is None + assert pid_is_alive(server.pid) assert supervisor_pid_from_log(server_log) == supervisor_pid_before assert pid_is_alive(daphne_pid_before) assert pid_is_alive(sonic_pid_before) @@ -411,37 +417,15 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm ) assert "Stopping older ArchiveBox runner process" in update_stdout - deadline = time.time() + 180 - runner_pid_after = runner_pid_before - while time.time() < deadline: - with use_archivebox_db(tmp_path): - rows = list( - Process.objects.filter( - process_type=Process.TypeChoices.ORCHESTRATOR, - worker_type="worker_runner", - status=Process.StatusChoices.RUNNING, - ).values("pid"), - ) - for row in rows: - pid = int(row["pid"]) - if pid != runner_pid_before and pid_is_alive(pid): - runner_pid_after = pid - break - if runner_pid_after != runner_pid_before: - break - time.sleep(0.25) + supervisord_text = wait_for_log_count(supervisord_log, runner_spawn_text, runner_spawn_count + 1, timeout=30) + runner_pid_after = int(re.findall(r"spawned: 'worker_runner' with pid (\d+)", supervisord_text)[-1]) assert runner_pid_after != runner_pid_before + assert pid_is_alive(runner_pid_after) - deadline = time.time() + 180 - indexed_results: list[str] = [] - while time.time() < deadline: - with use_archivebox_db(tmp_path): - indexed_results = list( - ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True), - ) - if indexed_results and all(status in ArchiveResult.FINAL_STATES for status in indexed_results): - break - time.sleep(0.25) + with use_archivebox_db(tmp_path): + indexed_results = list( + ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True), + ) assert indexed_results assert all(status in ArchiveResult.FINAL_STATES for status in indexed_results) @@ -452,7 +436,7 @@ def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tm wait_for_pid_to_disappear(runner_pid_after, timeout=20) assert_no_processes_for_data_dir(tmp_path, timeout=12) finally: - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -498,8 +482,8 @@ def test_live_update_yields_to_server_then_reclaims_real_sqlite_indexing(tmp_pat server = start_archivebox_server(tmp_path, port=port, log_name="server-takes-real-sqlite-update.log", env=env) server_log = server.log_path wait_for_log(update_log, "A newer archivebox process took over the orchestrator, sonic", timeout=90) - assert update_proc.poll() is None - assert server.poll() is None + assert pid_is_alive(update_proc.pid) + assert pid_is_alive(server.pid) server_text = server_log.read_text(encoding="utf-8", errors="replace") # The older update process can yield orchestrator ownership just before # the server logs its takeover, but sonic must always move to the server. @@ -528,23 +512,17 @@ def test_live_update_yields_to_server_then_reclaims_real_sqlite_indexing(tmp_pat wait_for_pid_to_disappear(server_runner_pid, timeout=20) wait_for_pid_to_disappear(server_sonic_pid, timeout=20) - deadline = time.time() + 30 - indexed_results: list[str] = [] - while time.time() < deadline: - with use_archivebox_db(tmp_path): - indexed_results = list( - ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True), - ) - if indexed_results and all(status in ArchiveResult.FINAL_STATES for status in indexed_results): - break - time.sleep(0.25) + with use_archivebox_db(tmp_path): + indexed_results = list( + ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True), + ) assert indexed_results assert all(status in ArchiveResult.FINAL_STATES for status in indexed_results) assert_no_processes_for_data_dir(tmp_path, timeout=12) finally: - if update_proc is not None and update_proc.poll() is None: + if update_proc is not None: stop_archivebox_process(update_proc, signal.SIGTERM) - if server is not None and server.poll() is None: + if server is not None: stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -572,7 +550,7 @@ def test_live_repeated_server_startups_take_over_cleanly(tmp_path, initialized_a previous_server = servers[index - 1] previous_log = (tmp_path / f"server-chaos-{index - 1}.log").read_text(encoding="utf-8", errors="replace") current_log = log_path.read_text(encoding="utf-8", errors="replace") - assert previous_server.poll() is None + assert pid_is_alive(previous_server.pid) assert pid_is_alive(server_pids[index - 1]) assert "A newer archivebox process took over the orchestrator, server" in previous_log assert "Starting orchestrator, server" in current_log @@ -587,10 +565,9 @@ def test_live_repeated_server_startups_take_over_cleanly(tmp_path, initialized_a ) stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert returncode == 0, stderr or stdout - time.sleep(5) - assert servers[-1].poll() is None - assert all(server.poll() is None for server in servers) + assert pid_is_alive(servers[-1].pid) + assert all(pid_is_alive(server.pid) for server in servers) listener = subprocess.run( ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], capture_output=True, @@ -606,70 +583,20 @@ def test_live_repeated_server_startups_take_over_cleanly(tmp_path, initialized_a ) stop_archivebox_process(servers[-1], signal.SIGTERM) wait_for_log_count(previous_log_path, "Other newer archivebox process", previous_takeovers + 1, timeout=35) - assert servers[3].poll() is None + assert pid_is_alive(servers[3].pid) finally: for server in reversed(servers): - if server.poll() is None: - stop_archivebox_process(server, signal.SIGTERM) + stop_archivebox_process(server, signal.SIGTERM) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @pytest.mark.timeout(420) -def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, initialized_archive, recursive_test_site): - plugins_root = tmp_path / "runtime_plugins" - marker_dir = tmp_path / "slow-plugin-markers" - plugin_dir = plugins_root / "slow_exit" - plugin_dir.mkdir(parents=True, exist_ok=True) - counter_hook = plugin_dir / "on_Snapshot__08_counter.sh" - counter_hook.write_text( - "\n".join( - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - f"marker_dir={str(marker_dir)!r}", - 'mkdir -p "$marker_dir/counter-seen"', - 'snapshot_key="${SNAPSHOT_ID:-$(basename "${SNAP_DIR:-unknown}")}"', - 'seen_file="$marker_dir/counter-seen/$snapshot_key"', - 'if [[ -e "$seen_file" ]]; then', - ' echo "$snapshot_key" >> "$marker_dir/counter-duplicates.txt"', - " exit 42", - "fi", - 'touch "$seen_file"', - 'echo "$snapshot_key" >> "$marker_dir/counter-runs.txt"', - "", - ], - ), - encoding="utf-8", - ) - counter_hook.chmod(0o755) - hook = plugin_dir / "on_Snapshot__09_slow_exit.sh" - hook.write_text( - "\n".join( - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - f"marker_dir={str(marker_dir)!r}", - 'mkdir -p "$marker_dir"', - 'echo $$ >> "$marker_dir/hook-pids.txt"', - 'touch "$marker_dir/hook-started"', - "trap 'touch \"$marker_dir/hook-stopped\"; exit 143' TERM INT HUP", - 'while [[ ! -f "$marker_dir/allow-finish" ]]; do sleep 0.1; done', - 'touch "$marker_dir/hook-finished"', - "", - ], - ), - encoding="utf-8", - ) - hook.chmod(0o755) - - env = cli_env(live=True, plugins_root=plugins_root) +def test_live_background_add_survives_server_exit_and_foreground_run_reclaims(tmp_path, initialized_archive, recursive_test_site): + env = cli_env(live=True, SEARCH_BACKEND_ENGINE="ripgrep") port = get_free_port() server = None server2 = None - server3 = None - add_proc = None - add_proc2 = None try: server = start_archivebox_server(tmp_path, port=port, log_name="server-add-owner-1.log", env=env) server_log = server.log_path @@ -683,95 +610,48 @@ def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, initi ) update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode assert update_returncode == 0, update_stderr or update_stdout - assert server.poll() is None + assert pid_is_alive(server.pid) assert pid_is_alive(supervisor_pid_before) assert supervisor_pid_from_log(server_log) == supervisor_pid_before - add_log = tmp_path / "archivebox-add-1.log" - add_log_handle = add_log.open("w", encoding="utf-8") - add_proc = run_archivebox_cmd( + add = run_archivebox_cmd( [ "add", + "--bg", "--depth=1", - "--max-urls=2", + "--max-urls=20", "--crawl-max-size=50mb", - "--plugins=wget,parse_html_urls,slow_exit", + "--plugins=wget,parse_html_urls", recursive_test_site["root_url"], - recursive_test_site["child_urls"][0], ], cwd=tmp_path, env=env, - stdout=add_log_handle, - stderr=subprocess.STDOUT, - start_new_session=True, - wait=False, + timeout=60, ) - add_log_handle.close() + assert add.returncode == 0, add.stderr or add.stdout + assert "background runner will process" in add.stdout - pid_file = marker_dir / "hook-pids.txt" - deadline = time.time() + 45 - hook_pids: list[int] = [] - while time.time() < deadline: - if pid_file.exists(): - hook_pids = [int(line.strip()) for line in pid_file.read_text().splitlines() if line.strip()] - if len(hook_pids) >= 1: - break - time.sleep(0.25) - assert len(hook_pids) >= 1 - - deadline = time.time() + 30 - snapshot_started = False - while time.time() < deadline: - with use_archivebox_db(tmp_path): - snapshot_started = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).exists() - if snapshot_started: - break - time.sleep(0.25) - assert snapshot_started - - os.kill(server.pid, signal.SIGTERM) - server.wait(timeout=20) - assert add_proc.poll() is None, "foreground add should keep owning its crawl after the server exits" + stop_archivebox_process(server, signal.SIGTERM) + server = None assert "Got SIGTERM" in server_log.read_text(encoding="utf-8", errors="replace") - - stop_archivebox_process(add_proc, signal.SIGKILL, timeout=30) - add_output = add_log.read_text(encoding="utf-8", errors="replace") - assert "Runner error" not in add_output - kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) + with use_archivebox_db(tmp_path): + crawl_id = str(Crawl.objects.get().id) + run = run_archivebox_cmd( + ["run", f"--crawl-id={crawl_id}"], + cwd=tmp_path, + env=env, + timeout=180, + ) + assert run.returncode == 0, run.stderr or run.stdout + server2 = start_archivebox_server(tmp_path, port=port, log_name="server-add-owner-2.log", env=env) - _server2_log = server2.log_path - (marker_dir / "allow-finish").touch() - - deadline = time.time() + 90 - crawls = [] - snapshots = [] - bad_results = [] - while time.time() < deadline: - with use_archivebox_db(tmp_path): - crawls = list(Crawl.objects.order_by("created_at").values_list("status", "retry_at")) - snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "status", "retry_at")) - bad_results = list( - ArchiveResult.objects.filter( - status__in=[ - ArchiveResult.StatusChoices.FAILED, - ArchiveResult.StatusChoices.SKIPPED, - ], - ).values_list("plugin", "status", "output_str"), - ) - if ( - crawls - and snapshots - and all(status == Crawl.StatusChoices.SEALED for status, _retry_at in crawls) - and all(status == Snapshot.StatusChoices.SEALED for _url, status, _retry_at in snapshots) - and not bad_results - ): - break - time.sleep(0.25) - - os.kill(server2.pid, signal.SIGTERM) - server2.wait(timeout=20) + assert get_http_response(port, host=f"archivebox.localhost:{port}").status_code < 500 + captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180) + assert "Root" in captured_text + stop_archivebox_process(server2, signal.SIGTERM) + server2 = None with use_archivebox_db(tmp_path): crawls = list(Crawl.objects.order_by("created_at").values_list("status", "retry_at")) snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "status", "retry_at")) @@ -787,20 +667,10 @@ def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, initi assert snapshots assert all(status == Crawl.StatusChoices.SEALED for status, _retry_at in crawls) assert all(status == Snapshot.StatusChoices.SEALED for _url, status, _retry_at in snapshots) - counter_runs = (marker_dir / "counter-runs.txt").read_text(encoding="utf-8").splitlines() - assert counter_runs - assert len(counter_runs) == len(set(counter_runs)) - assert not (marker_dir / "counter-duplicates.txt").exists() - - # The interrupted hook should be retried directly without rerunning the - # previous hook in the same plugin. That keeps plugin-level shell hooks - # idempotent across runner takeover instead of depending on each hook to - # detect partial prior work itself. assert not bad_results - assert (marker_dir / "hook-finished").exists() finally: - for proc in (add_proc, add_proc2, server, server2, server3): - if proc is not None and proc.poll() is None: + for proc in (server, server2): + if proc is not None: stop_archivebox_process(proc, signal.SIGTERM, timeout=10) kill_processes_for_data_dir(tmp_path) assert_no_processes_for_data_dir(tmp_path, timeout=12) @@ -809,20 +679,41 @@ def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, initi # Utility-level takeover selection tests. -def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path): - from archivebox.machine.models import Machine, Process +def _start_archivebox_shell(tmp_path: Path): + process = run_archivebox_cmd( + ["manage", "shell"], + cwd=tmp_path, + env=cli_env(live=True), + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + capture_output=False, + start_new_session=True, + wait=False, + ) + assert process.pid is not None + assert process.stdin is not None + assert pid_is_alive(process.pid) + return process + + +def _stop_archivebox_shells(processes) -> None: + for process in processes: + if process.stdin is not None and not process.stdin.closed: + process.stdin.close() + for process in processes: + process.wait(timeout=20) + assert not pid_is_alive(process.pid) + + +def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import runtime_stack_owner procs: list[subprocess.Popen[str]] = [] try: for process_type in (Process.TypeChoices.UPDATE, Process.TypeChoices.SERVER): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) Process.objects.create( machine=Machine.current(), @@ -833,7 +724,6 @@ def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path): pid=proc.pid, status=Process.StatusChoices.RUNNING, ) - time.sleep(0.05) owner = runtime_stack_owner(data_dir=tmp_path) @@ -841,31 +731,17 @@ def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path): assert owner.process_type == Process.TypeChoices.SERVER assert owner.pid == procs[-1].pid finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_runtime_stack_owner_keeps_server_over_newer_update(tmp_path): - from archivebox.machine.models import Machine, Process +def test_runtime_stack_owner_keeps_server_over_newer_update(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import runtime_stack_owner procs: list[subprocess.Popen[str]] = [] try: for process_type in (Process.TypeChoices.SERVER, Process.TypeChoices.UPDATE): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) Process.objects.create( machine=Machine.current(), @@ -876,7 +752,6 @@ def test_runtime_stack_owner_keeps_server_over_newer_update(tmp_path): pid=proc.pid, status=Process.StatusChoices.RUNNING, ) - time.sleep(0.05) owner = runtime_stack_owner(data_dir=tmp_path) @@ -884,31 +759,17 @@ def test_runtime_stack_owner_keeps_server_over_newer_update(tmp_path): assert owner.process_type == Process.TypeChoices.SERVER assert owner.pid == procs[0].pid finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_foreground_runner_owner_prefers_newer_update_over_server(tmp_path): - from archivebox.machine.models import Machine, Process +def test_foreground_runner_owner_prefers_newer_update_over_server(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import foreground_runner_owner, runtime_stack_owner procs: list[subprocess.Popen[str]] = [] try: for process_type in (Process.TypeChoices.SERVER, Process.TypeChoices.UPDATE): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) Process.objects.create( machine=Machine.current(), @@ -919,7 +780,6 @@ def test_foreground_runner_owner_prefers_newer_update_over_server(tmp_path): pid=proc.pid, status=Process.StatusChoices.RUNNING, ) - time.sleep(0.05) runtime_owner = runtime_stack_owner(data_dir=tmp_path) runner_owner = foreground_runner_owner(data_dir=tmp_path) @@ -930,31 +790,17 @@ def test_foreground_runner_owner_prefers_newer_update_over_server(tmp_path): assert runner_owner.process_type == Process.TypeChoices.UPDATE assert runner_owner.pid == procs[-1].pid finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_foreground_runner_owner_prefers_newer_server_over_update(tmp_path): - from archivebox.machine.models import Machine, Process +def test_foreground_runner_owner_prefers_newer_server_over_update(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import foreground_runner_owner, runtime_stack_owner procs: list[subprocess.Popen[str]] = [] try: for process_type in (Process.TypeChoices.UPDATE, Process.TypeChoices.SERVER): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) Process.objects.create( machine=Machine.current(), @@ -965,7 +811,6 @@ def test_foreground_runner_owner_prefers_newer_server_over_update(tmp_path): pid=proc.pid, status=Process.StatusChoices.RUNNING, ) - time.sleep(0.05) runtime_owner = runtime_stack_owner(data_dir=tmp_path) runner_owner = foreground_runner_owner(data_dir=tmp_path) @@ -976,19 +821,11 @@ def test_foreground_runner_owner_prefers_newer_server_over_update(tmp_path): assert runner_owner.process_type == Process.TypeChoices.SERVER assert runner_owner.pid == procs[-1].pid finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path): - from archivebox.machine.models import Machine, Process +def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE, runtime_stack_owner procs: list[subprocess.Popen[str]] = [] @@ -997,13 +834,7 @@ def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path) (Process.TypeChoices.SERVER, ""), (Process.TypeChoices.ORCHESTRATOR, RUNNER_ACTIVE_WORKER_TYPE), ): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) Process.objects.create( machine=Machine.current(), @@ -1014,7 +845,6 @@ def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path) pid=proc.pid, status=Process.StatusChoices.RUNNING, ) - time.sleep(0.05) owner = runtime_stack_owner(data_dir=tmp_path) @@ -1022,19 +852,11 @@ def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path) assert owner.process_type == Process.TypeChoices.SERVER assert owner.pid == procs[0].pid finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path): - from archivebox.machine.models import Machine, Process +def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import runtime_stack_owner procs: list[subprocess.Popen[str]] = [] @@ -1042,13 +864,7 @@ def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path newer_row = None try: for process_type in (Process.TypeChoices.UPDATE, Process.TypeChoices.SERVER): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) row = Process.objects.create( machine=Machine.current(), @@ -1063,13 +879,12 @@ def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path older_row = row else: newer_row = row - time.sleep(0.05) assert older_row is not None assert newer_row is not None - os.killpg(procs[-1].pid, signal.SIGTERM) - procs[-1].wait(timeout=5) + os.kill(procs[-1].pid, signal.SIGTERM) + procs[-1].wait(timeout=20) owner = runtime_stack_owner(data_dir=tmp_path) @@ -1079,31 +894,17 @@ def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path older_row.refresh_from_db() assert older_row.status == Process.StatusChoices.RUNNING finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_runtime_stack_owner_ignores_supervised_orphan_runner(tmp_path): - from archivebox.machine.models import Machine, Process +def test_runtime_stack_owner_ignores_supervised_orphan_runner(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE, runtime_stack_owner procs: list[subprocess.Popen[str]] = [] try: for _ in range(2): - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) procs.append(proc) supervisor_row = Process.objects.create( @@ -1128,28 +929,14 @@ def test_runtime_stack_owner_ignores_supervised_orphan_runner(tmp_path): assert runtime_stack_owner(data_dir=tmp_path) is None finally: - for proc in procs: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - for proc in procs: - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells(procs) -def test_runtime_stack_owner_allows_top_level_runner_when_no_parent_command_exists(tmp_path): - from archivebox.machine.models import Machine, Process +def test_runtime_stack_owner_allows_top_level_runner_when_no_parent_command_exists(tmp_path, initialized_archive): + from archivebox.machine.models import Machine from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE, runtime_stack_owner - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(60)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - text=True, - start_new_session=True, - ) + proc = _start_archivebox_shell(tmp_path) try: runner_row = Process.objects.create( machine=Machine.current(), @@ -1166,10 +953,4 @@ def test_runtime_stack_owner_allows_top_level_runner_when_no_parent_command_exis assert owner is not None assert owner.id == runner_row.id finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGTERM) - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait(timeout=5) + _stop_archivebox_shells([proc]) diff --git a/archivebox/tests/test_ui_add_view.py b/archivebox/tests/test_ui_add_view.py index 3baabfbd..869717e2 100644 --- a/archivebox/tests/test_ui_add_view.py +++ b/archivebox/tests/test_ui_add_view.py @@ -6,6 +6,7 @@ from django.urls import reverse from archivebox.config.common import ArchiveBoxConfig from archivebox.core.models import Snapshot, Tag from archivebox.crawls.models import Crawl +from archivebox.machine.models import Machine from archivebox.personas.models import Persona from archivebox.services.runner import CrawlRunner from archivebox.workers.models import RETRY_AT_MAX @@ -27,9 +28,12 @@ def admin_user(db): ) -def test_add_view_renders_tag_editor_and_url_filter_fields(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +@pytest.fixture +def public_add_enabled(): + return Machine.from_json({"config": {"PUBLIC_ADD_VIEW": True}}) + +def test_add_view_renders_tag_editor_and_url_filter_fields(client, admin_user, public_add_enabled): response = client.get(reverse("add"), HTTP_HOST=WEB_HOST) form = response.context["form"] @@ -58,8 +62,7 @@ def test_add_view_renders_tag_editor_and_url_filter_fields(client, admin_user, m assert b"skip URLs you've previously saved" in response.content or b"skip URLs you've previously saved" in response.content -def test_add_view_admin_renders_plugin_config_grid(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_admin_renders_plugin_config_grid(client, admin_user): client.force_login(admin_user) response = client.get(reverse("add"), HTTP_HOST=ADMIN_HOST) @@ -167,8 +170,7 @@ def test_add_view_staff_user_cannot_override_raw_or_plugin_config(client): assert crawl.config.get("YTDLP_ARGS_EXTRA") != ["--exec", "touch /tmp/owned"] -def test_add_view_embeds_selected_persona_config_for_ui_hydration(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_embeds_selected_persona_config_for_ui_hydration(client, admin_user): client.force_login(admin_user) default_persona = Persona.get_or_create_default() default_persona.config = { @@ -189,8 +191,7 @@ def test_add_view_embeds_selected_persona_config_for_ui_hydration(client, admin_ assert persona_config_map["Private"]["effective_config"]["YTDLP_COOKIES_FILE"] == "/tmp/archivebox-private-cookies.txt" -def test_add_view_public_only_lists_public_personas(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_public_only_lists_public_personas(client, admin_user, public_add_enabled): secret_value = "SHOULD_NOT_LEAK_PUBLIC_PERSONA_SECRET" default_persona = Persona.get_or_create_default() default_persona.config = {"PERMISSIONS": "public", "NODE_BINARY": "/secret/node", "TWOCAPTCHA_API_KEY": secret_value} @@ -223,9 +224,9 @@ def test_persona_config_grid_allows_binary_fields(client, admin_user): assert b"plugin_config__wget__WGET_ENABLED" in response.content -def test_add_view_hides_search_backend_plugins(client, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") - monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "sqlite") +def test_add_view_hides_search_backend_plugins(client, public_add_enabled): + public_add_enabled.config["SEARCH_BACKEND_ENGINE"] = "sqlite" + public_add_enabled.save(update_fields=["config"]) response = client.get(reverse("add"), HTTP_HOST=WEB_HOST) form = response.context["form"] @@ -234,8 +235,7 @@ def test_add_view_hides_search_backend_plugins(client, monkeypatch): assert form.plugin_groups == [] -def test_add_view_creates_crawl_with_tag_and_url_filter_overrides(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_creates_crawl_with_tag_and_url_filter_overrides(client, admin_user): client.force_login(admin_user) response = client.post( @@ -320,8 +320,7 @@ def test_add_view_sanitizes_crawl_notes_before_safe_update(client, admin_user): assert "" not in crawl.notes -def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user): client.force_login(admin_user) response = client.post( @@ -351,8 +350,7 @@ def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user, mon assert crawl.config["ONLY_NEW"] is False -def test_add_view_selected_persona_wins_over_stale_config_override(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_selected_persona_wins_over_stale_config_override(client, admin_user): client.force_login(admin_user) private_persona = Persona.objects.create(name="Private", created_by=admin_user) private_persona.ensure_dirs() @@ -396,8 +394,7 @@ def test_add_view_selected_persona_wins_over_stale_config_override(client, admin assert runtime_config["COOKIES_FILE"] == str(private_cookies_file) -def test_add_view_applies_plugin_config_overrides(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_applies_plugin_config_overrides(client, admin_user): client.force_login(admin_user) response = client.post( @@ -439,8 +436,7 @@ def test_add_view_applies_plugin_config_overrides(client, admin_user, monkeypatc assert "NODE_BINARY" not in crawl.config -def test_add_view_public_submission_ignores_plugin_and_custom_config(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_public_submission_ignores_plugin_and_custom_config(client, admin_user, public_add_enabled): response = client.post( reverse("add"), @@ -492,8 +488,7 @@ def test_add_view_public_submission_ignores_plugin_and_custom_config(client, adm assert crawl.schedule is None -def test_add_view_queues_crawl_for_background_runner(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_queues_crawl_for_background_runner(client, admin_user): client.force_login(admin_user) response = client.post( @@ -527,8 +522,7 @@ def test_add_view_queues_crawl_for_background_runner(client, admin_user, monkeyp assert crawl.snapshot_set.count() == 0 -def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, admin_user): client.force_login(admin_user) response = client.post( @@ -563,8 +557,7 @@ def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, ad assert crawl.config.get("INDEX_ONLY") is not True -def test_add_view_extracts_urls_from_mixed_text_input(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_extracts_urls_from_mixed_text_input(client, admin_user): client.force_login(admin_user) response = client.post( @@ -614,8 +607,7 @@ def test_add_view_extracts_urls_from_mixed_text_input(client, admin_user, monkey assert crawl.snapshot_set.count() == 0 -def test_add_view_trims_trailing_punctuation_from_markdown_urls(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_trims_trailing_punctuation_from_markdown_urls(client, admin_user): client.force_login(admin_user) response = client.post( @@ -659,8 +651,7 @@ def test_add_view_trims_trailing_punctuation_from_markdown_urls(client, admin_us assert crawl.snapshot_set.count() == 0 -def test_add_view_exposes_api_token_for_tag_widget_autocomplete(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_ADD_VIEW", "true") +def test_add_view_exposes_api_token_for_tag_widget_autocomplete(client, admin_user): client.force_login(admin_user) response = client.get(reverse("add"), HTTP_HOST=ADMIN_HOST) @@ -676,8 +667,8 @@ def _create_tagged_snapshot(user, *, permissions="public"): return snapshot -def test_tags_autocomplete_requires_auth_when_public_index_disabled(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_INDEX", "false") +def test_tags_autocomplete_requires_auth_when_public_index_disabled(client, admin_user): + Machine.from_json({"config": {"PUBLIC_INDEX": False}}) _create_tagged_snapshot(admin_user) response = client.get( @@ -689,8 +680,8 @@ def test_tags_autocomplete_requires_auth_when_public_index_disabled(client, admi assert response.status_code == 401 -def test_tags_autocomplete_lists_only_public_snapshot_tags(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_INDEX", "true") +def test_tags_autocomplete_lists_only_public_snapshot_tags(client, admin_user): + Machine.from_json({"config": {"PUBLIC_INDEX": True}}) _create_tagged_snapshot(admin_user) _create_tagged_snapshot(admin_user, permissions="unlisted") Tag.objects.create(name="private-empty") @@ -705,8 +696,8 @@ def test_tags_autocomplete_lists_only_public_snapshot_tags(client, admin_user, m assert response.json()["tags"][0]["name"] == "archive" -def test_tags_autocomplete_allows_authenticated_user_when_public_index_disabled(client, admin_user, monkeypatch): - monkeypatch.setenv("PUBLIC_INDEX", "false") +def test_tags_autocomplete_allows_authenticated_user_when_public_index_disabled(client, admin_user): + Machine.from_json({"config": {"PUBLIC_INDEX": False}}) Tag.objects.create(name="archive") client.force_login(admin_user) diff --git a/archivebox/tests/test_ui_add_view_runtime.py b/archivebox/tests/test_ui_add_view_runtime.py index 4ffeda61..c255b730 100644 --- a/archivebox/tests/test_ui_add_view_runtime.py +++ b/archivebox/tests/test_ui_add_view_runtime.py @@ -1,5 +1,4 @@ import re -import time import json from pathlib import Path @@ -18,7 +17,7 @@ from .conftest import ( run_archivebox_cmd, start_archivebox_server, stop_server, - wait_for_http, + get_http_response, ) pytestmark = pytest.mark.django_db(transaction=True) @@ -140,37 +139,6 @@ def write_import_format_files(base_dir: Path) -> dict[str, Path]: return files -def wait_for_import_processing(cwd: Path, expected_urls: set[str], *, timeout: float = 120.0) -> None: - deadline = time.time() + timeout - while time.time() < deadline: - with use_archivebox_db(cwd): - snapshot_started = Snapshot.objects.filter(url__in=expected_urls).exists() - if snapshot_started: - return - time.sleep(1) - raise AssertionError("timed out waiting for import crawl processing to start") - - -def wait_for_expected_import_snapshots(cwd: Path, expected_urls: set[str], *, timeout: float = 180.0) -> None: - allowed_statuses = {Snapshot.StatusChoices.QUEUED, Snapshot.StatusChoices.STARTED, Snapshot.StatusChoices.SEALED} - deadline = time.time() + timeout - while time.time() < deadline: - with use_archivebox_db(cwd): - rows = list(Snapshot.objects.filter(url__in=expected_urls).values_list("url", "status")) - counts = {url: 0 for url in expected_urls} - bad_statuses = [] - for url, status in rows: - counts[url] += 1 - if status not in allowed_statuses: - bad_statuses.append((url, status)) - if all(count == 1 for count in counts.values()) and not bad_statuses: - return - time.sleep(1) - raise AssertionError( - f"timed out waiting for one queued/started/sealed snapshot per URL, got counts={counts}, bad_statuses={bad_statuses}", - ) - - def malicious_add_inputs(tmp_path: Path, *, safe_url: str) -> tuple[list[str], Path]: other_crawl_source = tmp_path / "sources" / "other_crawl_source.txt" other_crawl_source.parent.mkdir(parents=True, exist_ok=True) @@ -221,13 +189,12 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s port=port, PLUGINS="wget", PUBLIC_ADD_VIEW="True", - PYTEST_CURRENT_TEST="", ) create_admin_and_token(tmp_path) try: start_archivebox_server(tmp_path, env=env, port=port) - _wait_for_worker_state(tmp_path, "worker_runner", "RUNNING") + assert _worker_state(tmp_path, "worker_runner") == "RUNNING" _stop_worker(tmp_path, "worker_runner") assert _worker_state(tmp_path, "worker_runner") != "RUNNING" @@ -258,7 +225,7 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s ) assert response.status_code in (302, 303), response.text - _wait_for_worker_state(tmp_path, "worker_runner", "RUNNING") + assert _worker_state(tmp_path, "worker_runner") == "RUNNING" with use_archivebox_db(tmp_path): crawl = Crawl.objects.order_by("-created_at").first() assert crawl is not None @@ -274,7 +241,7 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s def _login_to_add_view(port: int) -> tuple[requests.Session, str]: session = requests.Session() - wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") + get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") login_page = session.get( f"http://127.0.0.1:{port}/admin/login/", headers={"Host": f"admin.archivebox.localhost:{port}"}, @@ -296,7 +263,7 @@ def _login_to_add_view(port: int) -> tuple[requests.Session, str]: allow_redirects=False, ) assert login_response.status_code in (302, 303), login_response.text - add_page = wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/add/") + add_page = get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/add/") assert add_page.status_code == 200 add_csrf_match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', add_page.text) assert add_csrf_match, add_page.text[:500] @@ -333,17 +300,6 @@ print("stopped") assert returncode == 0, stderr or stdout -def _wait_for_worker_state(cwd, worker_name: str, statename: str, timeout: int = 45) -> None: - deadline = time.time() + timeout - state = None - while time.time() < deadline: - state = _worker_state(cwd, worker_name) - if state == statename: - return - time.sleep(1) - raise AssertionError(f"Timed out waiting for {worker_name}={statename}, last state={state}") - - @pytest.mark.timeout(240) def test_public_add_view_depth_one_crawl_skips_unreadable_persona_profile_entries(tmp_path, recursive_test_site): init_archive(tmp_path) @@ -369,9 +325,10 @@ def test_public_add_view_depth_one_crawl_skips_unreadable_persona_profile_entrie try: start_archivebox_server(tmp_path, env=env, port=port) - add_page = wait_for_http(port, host=f"web.archivebox.localhost:{port}", path="/add/") + add_page = get_http_response(port, host=f"web.archivebox.localhost:{port}", path="/add/") assert add_page.status_code == 200 assert 'name="depth"' in add_page.text + _stop_worker(tmp_path, "worker_runner") response = requests.post( f"http://127.0.0.1:{port}/add/", @@ -396,27 +353,11 @@ def test_public_add_view_depth_one_crawl_skips_unreadable_persona_profile_entrie allow_redirects=False, ) assert response.status_code in (302, 303), response.text - - deadline = time.time() + 180 - while time.time() < deadline: - with use_archivebox_db(tmp_path): - crawl = Crawl.objects.order_by("-created_at").first() - depth_counts = get_depth_counts(tmp_path) - child_urls = set(Snapshot.objects.filter(depth=1).values_list("url", flat=True)) - root_status = Snapshot.objects.filter(url=recursive_test_site["root_url"], depth=0).values_list("status", flat=True).first() - failed_results = list( - ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.FAILED).values_list("plugin", "output_str"), - ) - if ( - depth_counts.get(0, 0) >= 1 - and set(recursive_test_site["child_urls"]).issubset(child_urls) - and root_status == Snapshot.StatusChoices.SEALED - ): - break - assert not failed_results - time.sleep(2) - else: - raise AssertionError(f"timed out waiting for depth=1 crawl, got depth counts {get_depth_counts(tmp_path)}") + with use_archivebox_db(tmp_path): + submitted_crawl = Crawl.objects.order_by("-created_at").get() + stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={submitted_crawl.id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout with use_archivebox_db(tmp_path): crawl = Crawl.objects.order_by("-created_at").first() @@ -462,10 +403,12 @@ def test_public_add_view_import_text_formats_preserve_metadata_and_resume_withou try: start_archivebox_server(tmp_path, env=env, port=port) - add_page = wait_for_http(port, host=f"web.archivebox.localhost:{port}", path="/add/") + add_page = get_http_response(port, host=f"web.archivebox.localhost:{port}", path="/add/") assert add_page.status_code == 200 assert 'name="url"' in add_page.text + _stop_worker(tmp_path, "worker_runner") + crawl_ids = [] for import_path in import_files.values(): source_text = import_path.read_text(encoding="utf-8") response = requests.post( @@ -491,24 +434,24 @@ def test_public_add_view_import_text_formats_preserve_metadata_and_resume_withou allow_redirects=False, ) assert response.status_code in (302, 303), response.text - deadline = time.time() + 60 - root_input = None - while time.time() < deadline: - with use_archivebox_db(tmp_path): - crawl = Crawl.objects.order_by("-created_at").first() - assert crawl is not None - assert crawl.urls == source_text - root_snapshot = crawl.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL).first() - if root_snapshot: - root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") - break - time.sleep(1) - assert root_input == source_text + with use_archivebox_db(tmp_path): + crawl = Crawl.objects.order_by("-created_at").first() + assert crawl is not None + assert crawl.urls == source_text + crawl_ids.append(crawl.id) - wait_for_import_processing(tmp_path, expected_urls) stop_server(tmp_path) + for crawl_id in crawl_ids: + run_result = run_archivebox_cmd(["run", f"--crawl-id={crawl_id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout + + with use_archivebox_db(tmp_path): + for crawl in Crawl.objects.order_by("created_at"): + root_snapshot = crawl.snapshot_set.get(url=Snapshot.INTERNAL_INPUT_URL) + root_input = (root_snapshot.output_dir / "staticfile" / "stdin.txt").read_text(encoding="utf-8") + assert root_input == crawl.urls + start_archivebox_server(tmp_path, env=env, port=port) - wait_for_expected_import_snapshots(tmp_path, expected_urls) public_index = requests.get( f"http://127.0.0.1:{port}/", @@ -566,8 +509,9 @@ def test_public_add_view_rejects_file_path_and_shell_injection_payloads(tmp_path try: start_archivebox_server(tmp_path, env=env, port=port) - add_page = wait_for_http(port, host=f"web.archivebox.localhost:{port}", path="/add/") + add_page = get_http_response(port, host=f"web.archivebox.localhost:{port}", path="/add/") assert add_page.status_code == 200 + _stop_worker(tmp_path, "worker_runner") response = requests.post( f"http://127.0.0.1:{port}/add/", @@ -593,10 +537,12 @@ def test_public_add_view_rejects_file_path_and_shell_injection_payloads(tmp_path ) assert response.status_code in (302, 303), response.text - wait_for_import_processing(tmp_path, {safe_url}) + with use_archivebox_db(tmp_path): + submitted_crawl = Crawl.objects.order_by("-created_at").get() stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={submitted_crawl.id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout start_archivebox_server(tmp_path, env=env, port=port) - wait_for_expected_import_snapshots(tmp_path, {safe_url}, timeout=120) public_index = requests.get( f"http://127.0.0.1:{port}/", @@ -629,7 +575,7 @@ def test_add_view_post_creates_schedule_over_server(tmp_path, recursive_test_sit try: start_archivebox_server(tmp_path, env=env, port=port) session = requests.Session() - wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") + get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") login_page = session.get( f"http://admin.archivebox.localhost:{port}/admin/login/", timeout=10, @@ -650,7 +596,7 @@ def test_add_view_post_creates_schedule_over_server(tmp_path, recursive_test_sit allow_redirects=False, ) assert login_response.status_code in (302, 303), login_response.text - wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/add/") + get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/add/") response = session.post( f"http://admin.archivebox.localhost:{port}/add/", @@ -703,7 +649,7 @@ def test_add_view_depth_two_crawl_renders_outputs_over_server(tmp_path, recursiv try: start_archivebox_server(tmp_path, env=env, port=port) session = requests.Session() - wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") + get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/") login_page = session.get( f"http://127.0.0.1:{port}/admin/login/", headers={"Host": f"admin.archivebox.localhost:{port}"}, @@ -725,10 +671,11 @@ def test_add_view_depth_two_crawl_renders_outputs_over_server(tmp_path, recursiv allow_redirects=False, ) assert login_response.status_code in (302, 303), login_response.text - add_page = wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/add/") + add_page = get_http_response(port, host=f"admin.archivebox.localhost:{port}", path="/add/") assert add_page.status_code == 200 assert 'name="depth"' in add_page.text assert 'name="url"' in add_page.text + _stop_worker(tmp_path, "worker_runner") response = session.post( f"http://127.0.0.1:{port}/add/", @@ -756,19 +703,12 @@ def test_add_view_depth_two_crawl_renders_outputs_over_server(tmp_path, recursiv allow_redirects=False, ) assert response.status_code in (302, 303), response.text - - deadline = time.time() + 180 - while time.time() < deadline: - depth_counts = get_depth_counts(tmp_path) - if ( - depth_counts.get(0, 0) >= 1 - and depth_counts.get(1, 0) >= len(recursive_test_site["child_urls"]) - and depth_counts.get(2, 0) >= len(recursive_test_site["deep_urls"]) - ): - break - time.sleep(2) - else: - raise AssertionError(f"timed out waiting for depth=2 crawl, got depth counts {get_depth_counts(tmp_path)}") + with use_archivebox_db(tmp_path): + submitted_crawl = Crawl.objects.order_by("-created_at").get() + stop_server(tmp_path) + run_result = run_archivebox_cmd(["run", f"--crawl-id={submitted_crawl.id}"], cwd=tmp_path, timeout=180, env=env) + assert run_result.returncode == 0, run_result.stderr or run_result.stdout + start_archivebox_server(tmp_path, env=env, port=port) with use_archivebox_db(tmp_path): depth_counts = get_depth_counts(tmp_path) diff --git a/archivebox/tests/test_ui_admin_archiveresult.py b/archivebox/tests/test_ui_admin_archiveresult.py index f878f70e..8fbf3302 100644 --- a/archivebox/tests/test_ui_admin_archiveresult.py +++ b/archivebox/tests/test_ui_admin_archiveresult.py @@ -1,25 +1,86 @@ """ArchiveResult admin UI tests.""" +import asyncio +import os +from importlib.resources import files +from pathlib import Path + import pytest from django.urls import reverse -from archivebox.tests.conftest import ADMIN_TEST_HOST +from archivebox.tests.conftest import ADMIN_TEST_HOST, resolve_abxpkg_binary_env -pytestmark = pytest.mark.django_db +pytestmark = pytest.mark.django_db(transaction=True) + + +@pytest.fixture +def projected_noresults(snapshot, cached_abxpkg_lib_dir): + from abx_dl.events import ProcessEvent, SnapshotEvent + from abx_dl.orchestrator import create_bus + from abx_dl.services.process_service import ProcessService as HookProcessService + from archivebox.core.models import ArchiveResult + from archivebox.services.archive_result_service import ArchiveResultService + from archivebox.services.process_service import ProcessService as PersistedProcessService + + plugin = "parse_txt_urls" + hook_name = "on_Snapshot__71_parse_txt_urls.py" + hook_path = Path(str(files("abx_plugins.plugins.parse_txt_urls").joinpath(hook_name))) + binary_env = resolve_abxpkg_binary_env(cached_abxpkg_lib_dir, deps_from=hook_path.parent / "config.json") + staticfile_dir = snapshot.output_dir / "staticfile" + output_dir = snapshot.output_dir / plugin + staticfile_dir.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) + (staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8") + bus = create_bus(name=f"test_admin_noresults_{snapshot.id}") + HookProcessService(bus, emit_jsonl=False, interactive_tty=False) + PersistedProcessService(bus) + ArchiveResultService(bus) + + async def run() -> None: + try: + snapshot_event = SnapshotEvent( + url=snapshot.url, + snapshot_id=str(snapshot.id), + output_dir=str(snapshot.output_dir), + ) + await bus.emit(snapshot_event).now() + await bus.emit( + ProcessEvent( + plugin_name=plugin, + hook_name=hook_name, + hook_path=str(hook_path), + hook_args=[f"--url={snapshot.url}"], + env={ + **binary_env, + "ABXPKG_LIB_DIR": str(cached_abxpkg_lib_dir), + "SNAP_DIR": str(snapshot.output_dir), + "PATH": f"{Path(os.sys.executable).parent}{os.pathsep}{os.environ['PATH']}", + }, + output_dir=str(output_dir), + timeout=60, + is_background=False, + url=snapshot.url, + process_type="hook", + worker_type="hook", + event_parent_id=snapshot_event.event_id, + ), + ).now() + await bus.wait_until_idle() + finally: + await bus.destroy(clear=False) + + asyncio.run(run()) + return ArchiveResult.objects.get(snapshot=snapshot, plugin=plugin, hook_name=hook_name) class TestArchiveResultAdminListView: - def test_list_view_renders_readonly_tags_and_noresults_status(self, client, admin_user, snapshot): + def test_list_view_renders_readonly_tags_and_noresults_status(self, client, admin_user, snapshot, projected_noresults): from archivebox.core.models import ArchiveResult, Tag tag = Tag.objects.create(name="Alpha Research") snapshot.tags.add(tag) - ArchiveResult.objects.create( - snapshot=snapshot, - plugin="title", - status=ArchiveResult.StatusChoices.NORESULTS, - output_str="No title found", - ) + assert projected_noresults.status == ArchiveResult.StatusChoices.NORESULTS + assert projected_noresults.process_id is not None client.force_login(admin_user) response = client.get(reverse("admin:core_archiveresult_changelist"), HTTP_HOST=ADMIN_TEST_HOST) diff --git a/archivebox/tests/test_ui_admin_config_widget.py b/archivebox/tests/test_ui_admin_config_widget.py index 829b3d4c..536703b2 100644 --- a/archivebox/tests/test_ui_admin_config_widget.py +++ b/archivebox/tests/test_ui_admin_config_widget.py @@ -1,21 +1,7 @@ from archivebox.base_models.admin import KeyValueWidget -def test_key_value_widget_renders_enum_autocomplete_metadata(monkeypatch): - monkeypatch.setattr( - KeyValueWidget, - "_get_config_options", - lambda self: { - "CHROME_WAIT_FOR": { - "plugin": "chrome", - "type": "string", - "default": "networkidle2", - "description": "Page load completion condition", - "enum": ["domcontentloaded", "load", "networkidle0", "networkidle2"], - }, - }, - ) - +def test_key_value_widget_renders_enum_autocomplete_metadata(): html = str( KeyValueWidget().render( "config", @@ -32,33 +18,10 @@ def test_key_value_widget_renders_enum_autocomplete_metadata(monkeypatch): assert "validateValueAgainstMeta_id_config" in html -def test_key_value_widget_renders_numeric_and_pattern_constraints(monkeypatch): - monkeypatch.setattr( - KeyValueWidget, - "_get_config_options", - lambda self: { - "TIMEOUT": { - "plugin": "base", - "type": "integer", - "default": 60, - "description": "Timeout in seconds", - "minimum": 5, - "maximum": 120, - }, - "CHROME_RESOLUTION": { - "plugin": "chrome", - "type": "string", - "default": "1440,2000", - "description": "Viewport resolution", - "pattern": "^\\d+,\\d+$", - }, - }, - ) - +def test_key_value_widget_renders_numeric_and_pattern_constraints(): html = str(KeyValueWidget().render("config", {}, attrs={"id": "id_config"})) - assert '"minimum": 5' in html - assert '"maximum": 120' in html + assert '"minimum": 0' in html assert '"pattern": "^\\\\d+,\\\\d+$"' in html assert "Expected: " in html assert "Example: " in html @@ -66,21 +29,8 @@ def test_key_value_widget_renders_numeric_and_pattern_constraints(monkeypatch): assert "coerceValueForStorage_id_config" in html -def test_key_value_widget_accepts_common_boolean_spellings(monkeypatch): - monkeypatch.setattr( - KeyValueWidget, - "_get_config_options", - lambda self: { - "DEBUG": { - "plugin": "base", - "type": "boolean", - "default": False, - "description": "Enable debug mode", - }, - }, - ) - - html = str(KeyValueWidget().render("config", {"DEBUG": "True"}, attrs={"id": "id_config"})) +def test_key_value_widget_accepts_common_boolean_spellings(): + html = str(KeyValueWidget().render("config", {"CHECK_SSL_VALIDITY": "True"}, attrs={"id": "id_config"})) assert "enumValues = ['True', 'False']" in html assert "raw.toLowerCase()" in html @@ -88,27 +38,8 @@ def test_key_value_widget_accepts_common_boolean_spellings(monkeypatch): assert "lowered === 'false' || raw === '0'" in html -def test_key_value_widget_shows_array_and_object_examples_and_binary_rules(monkeypatch): - monkeypatch.setattr( - KeyValueWidget, - "_get_config_options", - lambda self: { - "WGET_ARGS_EXTRA": { - "plugin": "wget", - "type": "array", - "default": [], - "description": "Extra arguments to append to wget command", - }, - "WGET_BINARY": { - "plugin": "wget", - "type": "string", - "default": "wget", - "description": "Path to wget binary", - }, - }, - ) - - html = str(KeyValueWidget().render("config", {}, attrs={"id": "id_config"})) +def test_key_value_widget_shows_array_and_object_examples_and_binary_rules(): + html = str(KeyValueWidget().render("config", {"NODE_BINARY": "node"}, attrs={"id": "id_config"})) assert 'Example: ["--extra-arg"]' in html assert "Example: wget or /usr/bin/wget" in html @@ -117,20 +48,7 @@ def test_key_value_widget_shows_array_and_object_examples_and_binary_rules(monke assert "Binary paths cannot contain quotes" in html -def test_key_value_widget_falls_back_to_binary_validation_for_unknown_binary_keys(monkeypatch): - monkeypatch.setattr( - KeyValueWidget, - "_get_config_options", - lambda self: { - "CHROME_BINARY": { - "plugin": "base", - "type": "string", - "default": "", - "description": "Resolved Chromium/Chrome binary path shared across plugins", - }, - }, - ) - +def test_key_value_widget_falls_back_to_binary_validation_for_unknown_binary_keys(): html = str( KeyValueWidget().render( "config", diff --git a/archivebox/tests/test_ui_admin_links.py b/archivebox/tests/test_ui_admin_links.py index 7ace161a..da381ad7 100644 --- a/archivebox/tests/test_ui_admin_links.py +++ b/archivebox/tests/test_ui_admin_links.py @@ -1,6 +1,7 @@ import pytest import subprocess from datetime import datetime, timezone as dt_timezone +from importlib.resources import files from pathlib import Path from django.contrib.admin.sites import AdminSite from django.contrib.messages import get_messages @@ -10,10 +11,60 @@ from django.urls import reverse import html from uuid import uuid4 +from archivebox.tests.conftest import cli_env, run_archivebox_cmd +from archivebox.tests.conftest import install_real_binary + pytestmark = pytest.mark.django_db +@pytest.fixture +def real_hook_result(tmp_path): + from archivebox.core.models import ArchiveResult + from archivebox.plugins.hooks import extract_records_from_process, run_hook + + snapshot = _create_snapshot() + snap_dir = Path(snapshot.output_dir) + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True, exist_ok=True) + (snap_dir / "source.txt").write_text("real admin link hook input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + process = run_hook( + hook_path, + output_dir, + config={ + "ABXPKG_LIB_DIR": str(tmp_path / "lib"), + "SNAP_DIR": str(snap_dir), + "SAFE_FLAG": "1", + "API_KEY": "super-secret-key", + "ACCESS_TOKEN": "super-secret-token", + "SHARED_SECRET": "super-secret-secret", + }, + timeout=30, + url=snapshot.url, + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + record = extract_records_from_process(process)[0] + hashes_file = output_dir / "hashes.json" + result = ArchiveResult.objects.create( + snapshot=snapshot, + plugin=record["plugin"], + hook_name=record["hook_name"], + process=process, + status=record["status"], + output_str=record["output_str"], + output_files={ + "hashes.json": { + "extension": "json", + "mimetype": "application/json", + "size": hashes_file.stat().st_size, + }, + }, + ) + return snapshot, process, result + + def _create_snapshot(): from archivebox.base_models.models import get_or_create_system_user_pk from archivebox.crawls.models import Crawl @@ -85,22 +136,26 @@ def _admin_get_request(path="/"): @pytest.fixture -def running_process_record(): +def running_process_record(initialized_archive): from archivebox.machine.models import Machine, Process, psutil - cmd = ["/bin/sleep", "60"] - popen = subprocess.Popen( - cmd, - cwd=Path.cwd(), + cmd = ["archivebox", "manage", "shell"] + popen = run_archivebox_cmd( + ["manage", "shell"], + cwd=initialized_archive, + env=cli_env(live=True), + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + capture_output=False, + wait=False, ) try: os_process = psutil.Process(popen.pid) process = Process.objects.create( machine=Machine.current(refresh=True), - process_type=Process.TypeChoices.HOOK, - pwd=str(Path.cwd()), + process_type=Process.TypeChoices.ORCHESTRATOR, + pwd=str(initialized_archive), cmd=cmd, pid=popen.pid, started_at=datetime.fromtimestamp(os_process.create_time(), tz=dt_timezone.utc), @@ -108,46 +163,27 @@ def running_process_record(): ) yield process finally: - if popen.poll() is None: - popen.terminate() - try: - popen.wait(timeout=5) - except subprocess.TimeoutExpired: - popen.kill() - popen.wait(timeout=5) + assert popen.stdin is not None + popen.stdin.close() + popen.wait(timeout=20) -def test_archiveresult_admin_links_plugin_and_process(): +def test_archiveresult_admin_links_plugin_and_process(real_hook_result): from archivebox.core.admin_archiveresults import ArchiveResultAdmin, render_archiveresults_list from archivebox.core.models import ArchiveResult - from archivebox.machine.models import Process - snapshot = _create_snapshot() - iface = _create_iface(_create_machine()) - process = Process.objects.create( - machine=iface.machine, - iface=iface, - process_type=Process.TypeChoices.HOOK, - pwd=str(snapshot.output_dir / "wget"), - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) - result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg.py", - process=process, - status=ArchiveResult.StatusChoices.SUCCEEDED, - ) + snapshot, process, result = real_hook_result + iface = process.iface + assert iface is not None admin = ArchiveResultAdmin(ArchiveResult, AdminSite()) plugin_html = str(admin.plugin_with_icon(result)) process_html = str(admin.process_link(result)) - assert "/admin/environment/plugins/builtin.wget/" in plugin_html + assert "/admin/environment/plugins/builtin.hashes/" in plugin_html assert f"/admin/machine/process/{process.id}/change" in process_html - assert f"{str(process.id)[-8:]}" in process_html + assert f"{process.pid}" in process_html assert "-" not in process_html machine_html = str(admin.machine_link(result)) @@ -156,42 +192,21 @@ def test_archiveresult_admin_links_plugin_and_process(): inline_html = str(render_archiveresults_list(ArchiveResult.objects.filter(id=result.id))) assert f"/admin/machine/process/{process.id}/change" in inline_html - assert f">{str(process.id)[-8:]}" in inline_html + assert f">{process.pid}" in inline_html assert ">-" not in inline_html -def test_deleting_binary_and_process_records_preserves_results(): +@pytest.mark.django_db(transaction=True) +def test_deleting_binary_and_process_records_preserves_results(real_hook_result): from archivebox.core.admin_archiveresults import ArchiveResultAdmin, build_abx_dl_replay_command, render_archiveresults_list from archivebox.core.models import ArchiveResult from archivebox.machine.admin import ProcessAdmin - from archivebox.machine.models import Binary, Process + from archivebox.machine.models import Process - snapshot = _create_snapshot() - machine = _create_machine() - binary = Binary.objects.create( - machine=machine, - name="wget", - abspath="/usr/bin/wget", - version="1.21.2", - binprovider="env", - binproviders="env", - status=Binary.StatusChoices.INSTALLED, - ) - process = Process.objects.create( - machine=machine, - binary=binary, - process_type=Process.TypeChoices.HOOK, - pwd=str(snapshot.output_dir / "wget"), - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) - result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg.py", - process=process, - status=ArchiveResult.StatusChoices.SUCCEEDED, - ) + snapshot, process, result = real_hook_result + binary = install_real_binary("python3", machine=process.machine) + process.binary = binary + process.save(update_fields=["binary"]) binary.delete() process.refresh_from_db() @@ -220,7 +235,7 @@ def test_deleting_binary_and_process_records_preserves_results(): assert admin.process_link(result) == "-" assert admin.machine_link(result) == "-" assert "cd " in build_abx_dl_replay_command(result) - assert "wget" in render_archiveresults_list(ArchiveResult.objects.filter(id=result.id)) + assert "hashes" in render_archiveresults_list(ArchiveResult.objects.filter(id=result.id)) def test_snapshot_admin_zip_links(): @@ -273,35 +288,12 @@ def test_archiveresult_admin_zip_links(): assert html.escape(zip_url, quote=True) in str(admin.admin_actions(result)) -def test_archiveresult_admin_copy_command_redacts_sensitive_env_keys(): +def test_archiveresult_admin_copy_command_redacts_sensitive_env_keys(real_hook_result): from archivebox.core.admin_archiveresults import ArchiveResultAdmin from archivebox.core.models import ArchiveResult - from archivebox.machine.models import Process - snapshot = _create_snapshot() - iface = _create_iface(_create_machine()) - process = Process.objects.create( - machine=iface.machine, - iface=iface, - process_type=Process.TypeChoices.HOOK, - pwd=str(snapshot.output_dir / "wget"), - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - env={ - "SAFE_FLAG": "1", - "API_KEY": "super-secret-key", - "ACCESS_TOKEN": "super-secret-token", - "SHARED_SECRET": "super-secret-secret", - }, - status=Process.StatusChoices.EXITED, - url="https://example.com", - ) - result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg.py", - process=process, - status=ArchiveResult.StatusChoices.SUCCEEDED, - ) + _, process, result = real_hook_result + assert process.env["SAFE_FLAG"] == "1" admin = ArchiveResultAdmin(ArchiveResult, AdminSite()) admin.request = _admin_get_request() @@ -317,30 +309,17 @@ def test_archiveresult_admin_copy_command_redacts_sensitive_env_keys(): assert "super-secret-secret" not in cmd_html -def test_process_admin_links_binary_and_iface(): +@pytest.mark.django_db(transaction=True) +def test_process_admin_links_binary_and_iface(real_hook_result): from archivebox.machine.admin import ProcessAdmin - from archivebox.machine.models import Binary, Process + from archivebox.machine.models import Process - machine = _create_machine() - iface = _create_iface(machine) - binary = Binary.objects.create( - machine=machine, - name="wget", - abspath="/usr/local/bin/wget", - version="1.21.2", - binprovider="env", - binproviders="env", - status=Binary.StatusChoices.INSTALLED, - ) - process = Process.objects.create( - machine=machine, - iface=iface, - binary=binary, - process_type=Process.TypeChoices.HOOK, - pwd="/tmp/wget", - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) + _, process, _ = real_hook_result + iface = process.iface + assert iface is not None + binary = install_real_binary("python3", machine=process.machine) + process.binary = binary + process.save(update_fields=["binary"]) admin = ProcessAdmin(Process, AdminSite()) @@ -358,9 +337,9 @@ def test_process_admin_kill_actions_only_terminate_running_processes(running_pro running = running_process_record exited = Process.objects.create( machine=Machine.current(), - process_type=Process.TypeChoices.HOOK, - pwd="/tmp/exited", - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], + process_type=Process.TypeChoices.ORCHESTRATOR, + pwd=running.pwd, + cmd=running.cmd, status=Process.StatusChoices.EXITED, ) @@ -377,17 +356,11 @@ def test_process_admin_kill_actions_only_terminate_running_processes(running_pro assert any("Skipped 1 process" in msg for msg in messages) -def test_process_admin_object_kill_action_redirects_and_skips_exited(): +def test_process_admin_object_kill_action_redirects_and_skips_exited(real_hook_result): from archivebox.machine.admin import ProcessAdmin - from archivebox.machine.models import Machine, Process + from archivebox.machine.models import Process - process = Process.objects.create( - machine=Machine.current(refresh=True), - process_type=Process.TypeChoices.HOOK, - pwd="/tmp/exited", - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) + _, process, _ = real_hook_result admin = ProcessAdmin(Process, AdminSite()) request = _admin_post_request(f"/admin/machine/process/{process.pk}/change/") @@ -402,35 +375,17 @@ def test_process_admin_object_kill_action_redirects_and_skips_exited(): assert any("Skipped 1 process" in msg for msg in messages) -def test_process_admin_output_summary_uses_archiveresult_output_files(): - from archivebox.core.models import ArchiveResult +def test_process_admin_output_summary_uses_archiveresult_output_files(real_hook_result): from archivebox.machine.admin import ProcessAdmin from archivebox.machine.models import Process + from archivebox.misc.logging_util import printable_filesize - snapshot = _create_snapshot() - machine = _create_machine() - process = Process.objects.create( - machine=machine, - process_type=Process.TypeChoices.HOOK, - pwd=str(snapshot.output_dir / "wget"), - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) - ArchiveResult.objects.create( - snapshot=snapshot, - plugin="wget", - hook_name="on_Snapshot__06_wget.finite.bg.py", - process=process, - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_files={ - "index.html": {"extension": "html", "mimetype": "text/html", "size": 1024}, - "title.txt": {"extension": "txt", "mimetype": "text/plain", "size": "512"}, - }, - ) + _, process, result = real_hook_result + expected_size = sum(int(metadata["size"]) for metadata in result.output_files.values()) admin = ProcessAdmin(Process, AdminSite()) output_html = str(admin.output_summary(process)) - assert "2 files" in output_html - assert "1.5 KB" in output_html + assert f"{len(result.output_files)} file" in output_html + assert printable_filesize(expected_size) in output_html diff --git a/archivebox/tests/test_ui_admin_machine.py b/archivebox/tests/test_ui_admin_machine.py index 4d53ec9c..45f8d80f 100644 --- a/archivebox/tests/test_ui_admin_machine.py +++ b/archivebox/tests/test_ui_admin_machine.py @@ -1,20 +1,113 @@ """Machine, binary, and process admin UI tests.""" import uuid +import asyncio +import os +from importlib.resources import files +from pathlib import Path import pytest from django.urls import reverse from django.utils import timezone from archivebox.tests.conftest import ADMIN_TEST_HOST +from archivebox.tests.conftest import install_real_binary +from archivebox.tests.conftest import resolve_abxpkg_binary_env -pytestmark = pytest.mark.django_db +pytestmark = pytest.mark.django_db(transaction=True) + + +@pytest.fixture +def real_exited_hook_process(tmp_path): + from archivebox.plugins.hooks import run_hook + + snap_dir = tmp_path / "snapshot" + output_dir = snap_dir / "hashes" + output_dir.mkdir(parents=True) + (snap_dir / "source.txt").write_text("real admin hook input", encoding="utf-8") + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath("on_Snapshot__93_hashes.py"))) + process = run_hook( + hook_path, + output_dir, + config={"ABXPKG_LIB_DIR": str(tmp_path / "lib"), "SNAP_DIR": str(snap_dir)}, + timeout=30, + url="https://example.com/admin-hook", + ) + process.refresh_from_db() + assert process.exit_code == 0, process.stderr + assert (output_dir / "hashes.json").is_file() + return process + + +@pytest.fixture +def real_projected_hash_result(snapshot, cached_abxpkg_lib_dir): + from abx_dl.events import ProcessEvent, SnapshotEvent + from abx_dl.orchestrator import create_bus + from abx_dl.services.process_service import ProcessService as HookProcessService + from archivebox.core.models import ArchiveResult + from archivebox.machine.models import Process + from archivebox.services.archive_result_service import ArchiveResultService + from archivebox.services.process_service import ProcessService as PersistedProcessService + + plugin = "hashes" + hook_name = "on_Snapshot__93_hashes.py" + hook_path = Path(str(files("abx_plugins.plugins.hashes").joinpath(hook_name))) + hook_config = hook_path.parent / "config.json" + binary_env = resolve_abxpkg_binary_env(cached_abxpkg_lib_dir, deps_from=hook_config) + output_dir = snapshot.output_dir / plugin + output_dir.mkdir(parents=True, exist_ok=True) + (snapshot.output_dir / "source.txt").write_text("real admin projection input", encoding="utf-8") + bus = create_bus(name=f"test_admin_hashes_{snapshot.id}") + HookProcessService(bus, emit_jsonl=False, interactive_tty=False) + PersistedProcessService(bus) + ArchiveResultService(bus) + + async def run() -> None: + try: + snapshot_event = SnapshotEvent( + url=snapshot.url, + snapshot_id=str(snapshot.id), + output_dir=str(snapshot.output_dir), + ) + await bus.emit(snapshot_event).now() + await bus.emit( + ProcessEvent( + plugin_name=plugin, + hook_name=hook_name, + hook_path=str(hook_path), + hook_args=[f"--url={snapshot.url}"], + env={ + **binary_env, + "ABXPKG_LIB_DIR": str(cached_abxpkg_lib_dir), + "SNAP_DIR": str(snapshot.output_dir), + "PATH": f"{Path(os.sys.executable).parent}{os.pathsep}{os.environ['PATH']}", + }, + output_dir=str(output_dir), + timeout=60, + is_background=False, + url=snapshot.url, + process_type="hook", + worker_type="hook", + event_parent_id=snapshot_event.event_id, + ), + ).now() + await bus.wait_until_idle() + finally: + await bus.destroy(clear=False) + + asyncio.run(run()) + process = Process.objects.filter(pwd=str(output_dir)).order_by("-created_at").first() + assert process is not None + process.refresh_from_db() + assert process.exit_code == 0, (process.stdout, process.stderr) + result = ArchiveResult.objects.get(snapshot=snapshot, plugin=plugin, hook_name=hook_name) + return process, result class TestMachineAdmin: def test_binary_change_view_renders(self, client, admin_user, db): """Binary admin change form should load without FieldError.""" - from archivebox.machine.models import Machine, Binary + from archivebox.machine.models import Machine machine = Machine.objects.create( guid=f"test-guid-{uuid.uuid4()}", @@ -31,64 +124,36 @@ class TestMachineAdmin: os_kernel="test-kernel", stats={}, ) - binary = Binary.objects.create( - machine=machine, - name="gallery-dl", - binproviders="env", - binprovider="env", - abspath="/opt/homebrew/bin/gallery-dl", - version="1.26.9", - sha256="abc123", - status=Binary.StatusChoices.INSTALLED, - ) + binary = install_real_binary("python3", machine=machine) client.force_login(admin_user) url = f"/admin/machine/binary/{binary.pk}/change/" response = client.get(url, HTTP_HOST=ADMIN_TEST_HOST) assert response.status_code == 200 - assert b"gallery-dl" in response.content + assert binary.name.encode() in response.content + assert binary.version.encode() in response.content - def test_process_change_view_renders_copyable_cmd_env_and_readonly_runtime_fields(self, client, admin_user, db): + def test_process_change_view_renders_copyable_cmd_env_and_readonly_runtime_fields( + self, + client, + admin_user, + real_exited_hook_process, + ): from datetime import timedelta - from archivebox.machine.models import Machine, Process - machine = Machine.objects.create( - guid=f"test-guid-{uuid.uuid4()}", - hostname="test-host", - hw_in_docker=False, - hw_in_vm=False, - hw_manufacturer="Test", - hw_product="Test Product", - hw_uuid=f"test-hw-{uuid.uuid4()}", - os_arch="x86_64", - os_family="darwin", - os_platform="darwin", - os_release="test", - os_kernel="test-kernel", - stats={}, - ) - process = Process.objects.create( - machine=machine, - process_type=Process.TypeChoices.HOOK, - status=Process.StatusChoices.EXITED, - pwd="/tmp/archivebox", - cmd=["python", "/tmp/job.py", "--url=https://example.com"], - env={ + process = real_exited_hook_process + process.env.update( + { "ENABLED": True, "API_KEY": "super-secret-key", "ACCESS_TOKEN": "super-secret-token", "SHARED_SECRET": "super-secret-secret", }, - timeout=90, - pid=54321, - exit_code=0, - stdout="job stdout\nline 2", - stderr="job stderr\nline 2", - url="https://example.com/status", - started_at=timezone.now() - timedelta(seconds=52), - ended_at=timezone.now(), ) + process.started_at = timezone.now() - timedelta(seconds=52) + process.ended_at = timezone.now() + process.save(update_fields=["env", "started_at", "ended_at"]) client.force_login(admin_user) url = reverse("admin:machine_process_change", args=[process.pk]) @@ -96,10 +161,9 @@ class TestMachineAdmin: assert response.status_code == 200 assert b"Kill" in response.content - assert b"python /tmp/job.py --url=https://example.com" in response.content + assert b"on_Snapshot__93_hashes.py" in response.content assert b"ENABLED=True" in response.content - assert b"job stdout" in response.content - assert b"job stderr" in response.content + assert b"ArchiveResult" in response.content assert b"52s" in response.content assert b"API_KEY=" not in response.content assert b"ACCESS_TOKEN=" not in response.content @@ -117,16 +181,10 @@ class TestMachineAdmin: assert b'name="started_at"' not in response.content assert b'name="ended_at"' not in response.content - def test_process_kill_object_action_is_post_only(self, admin_client, db): - from archivebox.machine.models import Machine, Process + def test_process_kill_object_action_is_post_only(self, admin_client, real_exited_hook_process): + from archivebox.machine.models import Process - process = Process.objects.create( - machine=Machine.current(refresh=True), - process_type=Process.TypeChoices.HOOK, - pwd="/tmp/exited", - cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"], - status=Process.StatusChoices.EXITED, - ) + process = real_exited_hook_process action_url = reverse("admin:machine_process_actions", kwargs={"pk": process.pk, "tool": "kill_process"}) change_response = admin_client.get(reverse("admin:machine_process_change", args=[process.pk]), HTTP_HOST=ADMIN_TEST_HOST) @@ -143,46 +201,14 @@ class TestMachineAdmin: process.refresh_from_db() assert process.status == Process.StatusChoices.EXITED - def test_process_list_view_shows_duration_snapshot_and_crawl_columns(self, client, admin_user, snapshot, db): - from datetime import timedelta - from archivebox.core.models import ArchiveResult - from archivebox.machine.models import Machine, Process - - machine = Machine.objects.create( - guid=f"list-guid-{uuid.uuid4()}", - hostname="list-host", - hw_in_docker=False, - hw_in_vm=False, - hw_manufacturer="Test", - hw_product="Test Product", - hw_uuid=f"list-hw-{uuid.uuid4()}", - os_arch="x86_64", - os_family="darwin", - os_platform="darwin", - os_release="test", - os_kernel="test-kernel", - stats={}, - ) - process = Process.objects.create( - machine=machine, - process_type=Process.TypeChoices.HOOK, - status=Process.StatusChoices.EXITED, - pwd="/tmp/archivebox", - cmd=["python", "/tmp/job.py"], - env={}, - pid=12345, - exit_code=0, - started_at=timezone.now() - timedelta(milliseconds=10), - ended_at=timezone.now(), - ) - ArchiveResult.objects.create( - snapshot=snapshot, - process=process, - plugin="title", - hook_name="on_Snapshot__54_title", - status="succeeded", - output_str="Example Domain", - ) + def test_process_list_view_shows_duration_snapshot_and_crawl_columns( + self, + client, + admin_user, + snapshot, + real_projected_hash_result, + ): + process, result = real_projected_hash_result client.force_login(admin_user) response = client.get(reverse("admin:machine_process_changelist"), HTTP_HOST=ADMIN_TEST_HOST) @@ -191,7 +217,9 @@ class TestMachineAdmin: assert b"Duration" in response.content assert b"Snapshot" in response.content assert b"Crawl" in response.content - assert b"0.01s" in response.content + assert result.status == "succeeded" + assert process.started_at is not None + assert process.ended_at is not None changelist = response.context["cl"] row = next(obj for obj in changelist.result_list if obj.pk == process.pk) diff --git a/archivebox/tests/test_ui_admin_snapshot.py b/archivebox/tests/test_ui_admin_snapshot.py index 908b1df9..7d9e6c5e 100644 --- a/archivebox/tests/test_ui_admin_snapshot.py +++ b/archivebox/tests/test_ui_admin_snapshot.py @@ -1,8 +1,9 @@ """Snapshot model and admin UI tests.""" -import json +import shutil import warnings from pathlib import Path +from threading import Thread from types import SimpleNamespace import pytest @@ -10,11 +11,112 @@ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.core.paginator import UnorderedObjectListWarning from django.test import RequestFactory from django.urls import reverse -from django.utils import timezone from archivebox.tests.conftest import ADMIN_TEST_HOST +from archivebox.tests.test_archive_result_service import _run_shipped_snapshot_hook -pytestmark = pytest.mark.django_db +pytestmark = pytest.mark.django_db(transaction=True) +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def real_hash_projection(snapshot, cached_abxpkg_lib_dir): + snapshot.output_dir.mkdir(parents=True, exist_ok=True) + (snapshot.output_dir / "source.txt").write_text("real snapshot admin input", encoding="utf-8") + return _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=cached_abxpkg_lib_dir, + ) + + +@pytest.fixture +def real_failed_title_projection(snapshot, cached_abxpkg_lib_dir): + return _run_shipped_snapshot_hook( + snapshot, + plugin="title", + hook_name="on_Snapshot__54_title.js", + lib_dir=cached_abxpkg_lib_dir, + expected_exit_codes=(1,), + ) + + +@pytest.fixture +def real_noresults_projection(snapshot, cached_abxpkg_lib_dir): + staticfile_dir = snapshot.output_dir / "staticfile" + staticfile_dir.mkdir(parents=True, exist_ok=True) + (staticfile_dir / "input.txt").write_text("plain text without links", encoding="utf-8") + return _run_shipped_snapshot_hook( + snapshot, + plugin="parse_txt_urls", + hook_name="on_Snapshot__71_parse_txt_urls.py", + lib_dir=cached_abxpkg_lib_dir, + ) + + +@pytest.fixture +def real_parse_projection(snapshot, cached_abxpkg_lib_dir): + staticfile_dir = snapshot.output_dir / "staticfile" + staticfile_dir.mkdir(parents=True, exist_ok=True) + (staticfile_dir / "input.txt").write_text("https://example.org/parsed\n", encoding="utf-8") + return _run_shipped_snapshot_hook( + snapshot, + plugin="parse_txt_urls", + hook_name="on_Snapshot__71_parse_txt_urls.py", + lib_dir=cached_abxpkg_lib_dir, + ) + + +@pytest.fixture +def running_wget_projection(snapshot, blocking_http_server): + from django.utils import timezone + + from archivebox.core.models import ArchiveResult, Snapshot + from archivebox.crawls.models import Crawl + from archivebox.services.runner import run_due_snapshot + + now = timezone.now() + Crawl.objects.filter(pk=snapshot.crawl_id).update(status=Crawl.StatusChoices.STARTED, retry_at=now, modified_at=now) + Snapshot.objects.filter(pk=snapshot.pk).update( + status=Snapshot.StatusChoices.QUEUED, + retry_at=now, + downloaded_at=None, + url=blocking_http_server.url, + ) + snapshot.refresh_from_db() + [result] = snapshot.create_pending_archiveresults(hooks=[("wget", "on_Snapshot__06_wget.finite.bg")]) + errors = [] + + def run_snapshot(): + try: + assert run_due_snapshot(snapshot, lock_seconds=60) is True + except BaseException as err: + errors.append(err) + finally: + blocking_http_server.request_started.set() + + runner = Thread(target=run_snapshot, name="archivebox-test-admin-wget-runner") + runner.start() + blocking_http_server.request_started.wait() + assert errors == [] + result.refresh_from_db() + assert result.status == ArchiveResult.StatusChoices.STARTED + yield result + blocking_http_server.release_response.set() + runner.join() + assert errors == [] + + +@pytest.fixture +def real_skipped_hash_projection(snapshot, cached_abxpkg_lib_dir): + return _run_shipped_snapshot_hook( + snapshot, + plugin="hashes", + hook_name="on_Snapshot__93_hashes.py", + lib_dir=cached_abxpkg_lib_dir, + env={"HASHES_ENABLED": "False"}, + ) def test_snapshot_changelist_uses_stable_ordering_without_unordered_paginator_warning(admin_client, snapshot): @@ -32,14 +134,13 @@ def test_snapshot_changelist_uses_stable_ordering_without_unordered_paginator_wa assert b"Searching matching snapshots..." in response.content -def test_snapshot_changelist_preview_uses_prefetched_output_files(admin_client, snapshot): +def test_snapshot_changelist_preview_uses_prefetched_output_files(admin_client, snapshot, real_hash_projection): from archivebox.core.models import ArchiveResult - ArchiveResult.objects.create( - snapshot=snapshot, + _process, result = real_hash_projection + ArchiveResult.objects.filter(pk=result.pk).update( plugin="screenshot", hook_name="on_Snapshot__40_screenshot.js", - status=ArchiveResult.StatusChoices.SUCCEEDED, output_size=128, output_files={"screenshot.png": {"size": 128, "root_relative": True}}, ) @@ -54,7 +155,7 @@ def test_snapshot_changelist_preview_uses_prefetched_output_files(admin_client, def test_snapshot_admin_tag_editor_escapes_tag_json_script_breakout(admin_client, snapshot): from archivebox.core.models import Tag - tag = Tag.objects.create(name="legacy-safe-tag") + tag = Tag.objects.create(name="safe-tag") snapshot.tags.add(tag) malicious_name = '' Tag.objects.filter(pk=tag.pk).update(name=malicious_name) @@ -69,17 +170,12 @@ def test_snapshot_admin_tag_editor_escapes_tag_json_script_breakout(admin_client assert b"</script><script id="archivebox-tag-xss">" in body -def test_snapshot_admin_archive_results_escape_extractor_output(admin_client, snapshot): +def test_snapshot_admin_archive_results_escape_extractor_output(admin_client, snapshot, real_hash_projection): from archivebox.core.models import ArchiveResult payload = '' - ArchiveResult.objects.create( - snapshot=snapshot, - plugin="title", - hook_name="on_Snapshot__54_title.js", - status=ArchiveResult.StatusChoices.SUCCEEDED, - output_str=payload, - ) + _process, result = real_hash_projection + ArchiveResult.objects.filter(pk=result.pk).update(output_str=payload) response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST) body = response.content @@ -90,67 +186,28 @@ def test_snapshot_admin_archive_results_escape_extractor_output(admin_client, sn assert b"<img src=x onerror="window.__archivebox_archiveresult_xss__=1">" in body -def test_snapshot_admin_archive_result_table_escapes_legacy_string_fields(admin_client, snapshot): - from uuid import uuid4 - +def test_snapshot_admin_archive_result_table_escapes_persisted_string_fields(admin_client, snapshot, real_hash_projection): from archivebox.core.models import ArchiveResult - from archivebox.machine.models import Binary, Machine, Process - machine = Machine.objects.create( - guid=f"xss-machine-{uuid4()}", - hostname='', - hw_in_docker=False, - hw_in_vm=False, - hw_manufacturer="Test", - hw_product="Test Product", - hw_uuid=f"xss-hw-{uuid4()}", - os_arch="arm64", - os_family="darwin", - os_platform="macOS", - os_release="14.0", - os_kernel="Darwin", - stats={}, - config={}, - ) - binary = Binary.objects.create( - machine=machine, - name="staticfile", - abspath="/usr/bin/staticfile", - version='v', - binprovider="env", - binproviders="env", - status=Binary.StatusChoices.INSTALLED, - ) - process = Process.objects.create( - machine=machine, - binary=binary, - process_type=Process.TypeChoices.HOOK, - pwd='/tmp/archivebox">', - cmd=["staticfile"], - status=Process.StatusChoices.EXITED, - ) - result = ArchiveResult.objects.create( - snapshot=snapshot, - plugin="staticfile", - hook_name="on_Snapshot__00_staticfile.py", - process=process, - status=ArchiveResult.StatusChoices.SUCCEEDED, + process, result = real_hash_projection + machine = process.machine + type(machine).objects.filter(pk=machine.pk).update(hostname='') + type(process).objects.filter(pk=process.pk).update(pwd='/tmp/archivebox">') + ArchiveResult.objects.filter(pk=result.pk).update( output_files={'evil">.txt': {"size": 12, "mimetype": "text/plain"}}, - output_str='staticfile/evil">.txt', + output_str='hashes/evil">.txt', + plugin='', ) - ArchiveResult.objects.filter(pk=result.pk).update(plugin='') response = admin_client.get(reverse("admin:core_snapshot_change", args=[snapshot.pk]), HTTP_HOST=ADMIN_TEST_HOST) body = response.content assert response.status_code == 200 assert b'