Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2815d205 | ||
|
|
bf4c1cfa20 | ||
|
|
f1f21aaaef | ||
|
|
29ab6fd05d | ||
|
|
94e7906893 | ||
|
|
b36616cd5e | ||
|
|
be54377c67 | ||
|
|
26991ae950 | ||
|
|
04bdde0711 | ||
|
|
5262df1bec | ||
|
|
20cb72a64b | ||
|
|
04a01fd7b1 | ||
|
|
09ec9e1f03 | ||
|
|
c6542d791a |
No files matched your search
@@ -282,7 +282,14 @@ class ModelWithOutputDir(ModelWithUUID):
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
shutil.rmtree(path)
|
||||
# lstat also catches dangling symlinks and propagates mount and
|
||||
# permission errors rather than mistaking them for absence.
|
||||
try:
|
||||
path.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
raise OSError(f"Output path still exists after deletion: {path}")
|
||||
|
||||
def schedule_delete_cleanup(self, *, using: str | None = None) -> None:
|
||||
"""Capture output paths before DB deletion and remove them after commit."""
|
||||
@@ -294,7 +301,9 @@ class ModelWithOutputDir(ModelWithUUID):
|
||||
if cls._delete_signal_registered:
|
||||
return
|
||||
|
||||
def schedule_output_dir_cleanup(sender, instance, using, **kwargs):
|
||||
def schedule_output_dir_cleanup(sender, instance, using, origin=None, **kwargs):
|
||||
if getattr(origin, "_output_files_deleted", False):
|
||||
return
|
||||
if not isinstance(instance, ModelWithOutputDir):
|
||||
return
|
||||
instance.schedule_delete_cleanup(using=using)
|
||||
|
||||
@@ -87,9 +87,8 @@ def remove(
|
||||
# Delete one snapshot at a time. Each ``.delete()`` is its own short
|
||||
# Django-atomic block, so the writer lock is released between rows and
|
||||
# an in-flight daemon transaction can interleave instead of deadlocking.
|
||||
# Filesystem cleanup for each row is scheduled via ``transaction.on_commit``
|
||||
# in ``base_models/models.py`` and runs AFTER its row's tx commits — so
|
||||
# rmtree doesn't hold the lock either.
|
||||
# Snapshot.delete() removes and verifies filesystem outputs first, outside
|
||||
# the short database delete transaction. Failed cleanup retains the row.
|
||||
#
|
||||
deleted_snapshot_pks = []
|
||||
timed_out = False
|
||||
|
||||
@@ -34,6 +34,25 @@ ConfigPayload = dict[str, object]
|
||||
PluginSchemaDocuments = dict[str, dict[str, Any]]
|
||||
LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
|
||||
|
||||
|
||||
def _is_cookie_file_config_key(key: str) -> bool:
|
||||
return key in {"COOKIES_FILE", "AUTH_STORAGE_FILE"} or key.endswith("_COOKIES_FILE")
|
||||
|
||||
|
||||
def _resolve_cookie_file_paths(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep collection-relative auth paths valid after hooks change cwd."""
|
||||
for key, value in config.items():
|
||||
if _is_cookie_file_config_key(key):
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
config[key] = ""
|
||||
continue
|
||||
file_path = Path(value).expanduser()
|
||||
if not file_path.is_absolute():
|
||||
file_path = CONSTANTS.DATA_DIR / file_path
|
||||
config[key] = str(file_path.resolve())
|
||||
return config
|
||||
|
||||
|
||||
###################### Config ##########################
|
||||
|
||||
_STDOUT_CONSOLE = Console()
|
||||
@@ -468,6 +487,11 @@ class ArchivingConfig(BaseConfigSet):
|
||||
COOKIES_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
AUTH_STORAGE_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
@field_validator("COOKIES_FILE", "AUTH_STORAGE_FILE", mode="before")
|
||||
@classmethod
|
||||
def empty_cookie_file_is_unset(cls, value):
|
||||
return None if isinstance(value, str) and not value.strip() else value
|
||||
|
||||
URL_DENYLIST: str = Field(
|
||||
default=(
|
||||
r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$"
|
||||
@@ -825,6 +849,8 @@ class ArchiveBoxBaseConfig(
|
||||
if runtime_overrides:
|
||||
config.update(normalize_runtime_config(runtime_overrides, json_safe=False))
|
||||
|
||||
_resolve_cookie_file_paths(config)
|
||||
|
||||
# Hooks should only see concrete plugin-local flags, never the
|
||||
# ArchiveBox selectors used to derive them.
|
||||
config.pop("PLUGINS", None)
|
||||
@@ -849,6 +875,13 @@ class ArchiveBoxBaseConfig(
|
||||
lib_dir = CONSTANTS.DATA_DIR / lib_dir
|
||||
self.ABXPKG_LIB_DIR = lib_dir.resolve()
|
||||
|
||||
cookie_paths = {key: getattr(self, key) for key in type(self).model_fields if _is_cookie_file_config_key(key)}
|
||||
for key, value in _resolve_cookie_file_paths(cookie_paths).items():
|
||||
if key in {"COOKIES_FILE", "AUTH_STORAGE_FILE"}:
|
||||
setattr(self, key, Path(value) if value else None)
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -52,6 +52,7 @@ from archivebox.core.models import Tag, Snapshot, ArchiveResult
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.admin_archiveresults import render_archiveresults_list
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.workers.models import RETRY_AT_MAX
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_CHOICES,
|
||||
PERMISSIONS_META,
|
||||
@@ -131,11 +132,11 @@ class SnapshotStatusListFilter(admin.SimpleListFilter):
|
||||
parameter_name = "snapshot_status"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return Snapshot.StatusChoices.choices
|
||||
return Snapshot._meta.get_field("status").choices
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value in Snapshot.StatusChoices.values:
|
||||
if value in dict(Snapshot._meta.get_field("status").choices):
|
||||
return queryset.filter(status=value)
|
||||
return queryset
|
||||
|
||||
@@ -992,10 +993,14 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return format_html(
|
||||
'<a class="snapshot-title-detail-hitbox" href="{}" aria-label="Open snapshot details"></a>'
|
||||
"{}"
|
||||
"{}"
|
||||
'<div class="snapshot-title-url">'
|
||||
'<a class="snapshot-original-url" href="{}">{}<code style="user-select: all;">{}</code></a>'
|
||||
"</div>",
|
||||
detail_url,
|
||||
format_html('<strong class="snapshot-delete-status">{}</strong>', "Deleting — verifying file removal")
|
||||
if obj.status == Snapshot.DELETING_STATE
|
||||
else "",
|
||||
title_html,
|
||||
url_raw or obj.url,
|
||||
favicon_html,
|
||||
@@ -1535,25 +1540,14 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.action(
|
||||
description="🗑️ Delete",
|
||||
permissions=["delete"],
|
||||
)
|
||||
def delete_snapshots(self, request, queryset):
|
||||
"""Delete snapshots in a single transaction to avoid SQLite concurrency issues."""
|
||||
from django.db import transaction
|
||||
|
||||
total = queryset.count()
|
||||
|
||||
# Get list of IDs to delete first (outside transaction)
|
||||
ids_to_delete = list(queryset.values_list("pk", flat=True))
|
||||
|
||||
# Delete everything in a single atomic transaction
|
||||
with transaction.atomic():
|
||||
deleted_count, _ = Snapshot.objects.filter(pk__in=ids_to_delete).delete()
|
||||
|
||||
"""Keep the rows until the runner removes and verifies their files."""
|
||||
total = queryset.update(status=Snapshot.DELETING_STATE, retry_at=RETRY_AT_MAX, modified_at=timezone.now())
|
||||
messages.success(
|
||||
request,
|
||||
mark_safe(
|
||||
f"Successfully deleted {total} Snapshots ({deleted_count} total objects including related records). Don't forget to scrub URLs from import logs (data/sources) and error logs (data/logs) if needed.",
|
||||
),
|
||||
f"Queued {total} snapshots for deletion. They remain visible as Deleting until the runner verifies their files are removed.",
|
||||
)
|
||||
|
||||
@admin.action(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.1 on 2026-09-30 03:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0057_repair_migrated_process_links"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="snapshot",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("queued", "Queued"),
|
||||
("started", "Started"),
|
||||
("paused", "Paused"),
|
||||
("sealed", "Sealed"),
|
||||
("deleting", "Deleting"),
|
||||
],
|
||||
db_index=True,
|
||||
default="queued",
|
||||
max_length=15,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
@@ -175,6 +176,19 @@ class SnapshotTag(models.Model):
|
||||
class SnapshotQuerySet(models.QuerySet):
|
||||
"""Custom QuerySet for Snapshot model with export methods that persist through .filter() etc."""
|
||||
|
||||
def delete(self):
|
||||
# Finish the SELECT before filesystem work, so no read cursor or SQLite
|
||||
# transaction is held while deleting remote archive payloads.
|
||||
snapshots = list(self.select_related("crawl__created_by"))
|
||||
total = 0
|
||||
counts = {}
|
||||
for snapshot in snapshots:
|
||||
deleted, details = snapshot.delete(using=self.db)
|
||||
total += deleted
|
||||
for label, count in details.items():
|
||||
counts[label] = counts.get(label, 0) + count
|
||||
return total, counts
|
||||
|
||||
def bulk_create(self, objs, *args, **kwargs):
|
||||
objs = list(objs)
|
||||
missing_crawl_ids = set()
|
||||
@@ -541,6 +555,7 @@ class SnapshotManager(models.Manager.from_queryset(SnapshotQuerySet)): # ty: ig
|
||||
|
||||
class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats, ModelWithQueue):
|
||||
BROWSER_EXTENSION_UPLOAD_HOOK_NAME = "on_Snapshot__archivebox_browser_extension_upload"
|
||||
DELETING_STATE = "deleting"
|
||||
|
||||
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||
@@ -574,7 +589,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
)
|
||||
retry_at = ModelWithQueue.RetryAtField(default=timezone.now)
|
||||
status = ModelWithQueue.StatusField(
|
||||
choices=ModelWithQueue.StatusChoices,
|
||||
choices=[*ModelWithQueue.StatusChoices.choices, (DELETING_STATE, "Deleting")],
|
||||
default=ModelWithQueue.StatusChoices.QUEUED,
|
||||
)
|
||||
config = models.JSONField(default=dict, null=False, blank=False, editable=True)
|
||||
@@ -661,6 +676,47 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
def __str__(self):
|
||||
return f"[{self.id}] {self.url[:64]}"
|
||||
|
||||
def output_paths_for_delete(self) -> tuple[Path, ...]:
|
||||
current = self.get_storage_path_for_version(self.fs_version)
|
||||
paths = [Path(self.output_dir), current, CONSTANTS.ARCHIVE_DIR / self.timestamp]
|
||||
if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
|
||||
paths.append(current.with_name(str(uuid.UUID(hex=self.id.hex))))
|
||||
if self.crawl_id:
|
||||
domain_dir = self.crawl.output_dir / CONSTANTS.SNAPSHOTS_DIR_NAME / self.extract_domain_from_url(self.url)
|
||||
paths.extend((domain_dir / str(self.id), domain_dir / str(uuid.UUID(hex=self.id.hex))))
|
||||
return tuple(dict.fromkeys(paths))
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
from django.db import connections
|
||||
|
||||
using = using or self._state.db or "default"
|
||||
if connections[using].in_atomic_block:
|
||||
raise RuntimeError("Snapshot filesystem deletion must run outside a database transaction")
|
||||
type(self).objects.using(using).filter(pk=self.pk).update(status=self.DELETING_STATE, retry_at=RETRY_AT_MAX)
|
||||
self.status = self.DELETING_STATE
|
||||
from archivebox.machine.models import Process
|
||||
|
||||
process_ids = self.archiveresult_set.filter(process__status=Process.StatusChoices.RUNNING).values_list("process_id", flat=True)
|
||||
for process in list(Process.objects.using(using).filter(pk__in=process_ids)):
|
||||
process.kill_tree()
|
||||
if process.is_running:
|
||||
raise OSError(f"Snapshot output writer is still running: {process.pk}")
|
||||
paths = self.output_paths_for_delete()
|
||||
self.delete_output_paths(paths)
|
||||
# Cascaded ArchiveResults share this directory. Their normal post-commit
|
||||
# cleanup would repeat filesystem work and update an already-deleted row.
|
||||
self._output_files_deleted = True
|
||||
return super().delete(using=using, keep_parents=keep_parents)
|
||||
|
||||
@classmethod
|
||||
def delete_requested(cls, *, batch_size=100):
|
||||
pending = list(cls.objects.filter(status=cls.DELETING_STATE).select_related("crawl__created_by")[:batch_size])
|
||||
for snapshot in pending:
|
||||
try:
|
||||
snapshot.delete()
|
||||
except OSError:
|
||||
logging.getLogger(__name__).exception("Snapshot %s file deletion failed; keeping its database row", snapshot.pk)
|
||||
|
||||
@classmethod
|
||||
def crawl_count_subquery(cls, *, status: str | None = None, outer_ref: str = "pk") -> QuerySet:
|
||||
"""Return a scalar subquery counting Snapshots for one outer Crawl."""
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.db.models import QuerySet
|
||||
def snapshot_status_values() -> tuple[str, ...]:
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
return tuple(Snapshot.StatusChoices.values)
|
||||
return tuple(value for value, _label in Snapshot._meta.get_field("status").choices)
|
||||
|
||||
|
||||
def normalize_snapshot_status(status: str | None) -> str | None:
|
||||
|
||||
@@ -255,40 +255,40 @@ class Machine(ModelWithHealthStats):
|
||||
**get_vm_info(),
|
||||
stats=get_host_stats(),
|
||||
)
|
||||
else:
|
||||
if timezone.now() >= machine.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
|
||||
for key, value in {
|
||||
"hostname": socket.gethostname(),
|
||||
**get_os_info(),
|
||||
**get_vm_info(),
|
||||
"stats": get_host_stats(),
|
||||
}.items():
|
||||
setattr(machine, key, value)
|
||||
machine.save(
|
||||
update_fields=[
|
||||
"hostname",
|
||||
"hw_in_docker",
|
||||
"hw_in_vm",
|
||||
"hw_manufacturer",
|
||||
"hw_product",
|
||||
"hw_uuid",
|
||||
"os_arch",
|
||||
"os_family",
|
||||
"os_platform",
|
||||
"os_release",
|
||||
"os_kernel",
|
||||
"stats",
|
||||
"modified_at",
|
||||
],
|
||||
)
|
||||
machine = cls._sanitize_config(machine)
|
||||
# Reconcile once, on the first Machine.current() call in this process.
|
||||
needs_recheck = timezone.now() >= machine.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL)
|
||||
# Reconcile before any save can mirror stale DB config over file edits.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
if needs_recheck:
|
||||
for key, value in {
|
||||
"hostname": socket.gethostname(),
|
||||
**get_os_info(),
|
||||
**get_vm_info(),
|
||||
"stats": get_host_stats(),
|
||||
}.items():
|
||||
setattr(machine, key, value)
|
||||
machine.save(
|
||||
update_fields=[
|
||||
"hostname",
|
||||
"hw_in_docker",
|
||||
"hw_in_vm",
|
||||
"hw_manufacturer",
|
||||
"hw_product",
|
||||
"hw_uuid",
|
||||
"os_arch",
|
||||
"os_family",
|
||||
"os_platform",
|
||||
"os_release",
|
||||
"os_kernel",
|
||||
"stats",
|
||||
"modified_at",
|
||||
],
|
||||
)
|
||||
machine = cls._sanitize_config(machine)
|
||||
# Publish only after initialization is complete.
|
||||
_CURRENT_MACHINE = machine
|
||||
return machine
|
||||
|
||||
@@ -65,6 +65,7 @@ from archivebox.services.resource_admission import RESOURCE_RECHECK_SECONDS, res
|
||||
from archivebox.config.common import (
|
||||
ArchiveBoxBaseConfig,
|
||||
normalize_runtime_config,
|
||||
_is_cookie_file_config_key,
|
||||
_plugin_enabled_config_keys,
|
||||
)
|
||||
from archivebox.misc.db import run_db_analyze_batch
|
||||
@@ -738,7 +739,14 @@ class CrawlRunner:
|
||||
current_process.save(update_fields=["iface", "machine", "modified_at"])
|
||||
self.persona = self.crawl.resolve_persona()
|
||||
self.base_config = get_config(crawl=self.crawl, overrides=self.config_overrides)
|
||||
self.derived_config = dict(machine.config or {})
|
||||
# The merged user payload owns auth paths and binary selections. The
|
||||
# install phase publishes resolved binaries; stale machine paths must
|
||||
# not override explicit runtime settings before that phase runs.
|
||||
self.derived_config = {
|
||||
key: value
|
||||
for key, value in (machine.config or {}).items()
|
||||
if not _is_cookie_file_config_key(key) and (not key.endswith("_BINARY") or value == self.base_config.get(key))
|
||||
}
|
||||
self.crawl_output_dir = str(self.crawl.output_dir)
|
||||
if self.persona:
|
||||
self.base_config.update(
|
||||
@@ -1989,6 +1997,7 @@ def run_pending_crawls(
|
||||
raise_if_shutdown_requested()
|
||||
now_monotonic = time.monotonic()
|
||||
if crawl_id is None and now_monotonic - last_retention_at >= (60.0 if daemon else 1.0):
|
||||
Snapshot.delete_requested(batch_size=100)
|
||||
for model in (ArchiveResult, Snapshot, Crawl, Process):
|
||||
# Keep the tight scheduler loop anchored on indexed delete_at
|
||||
# columns only. Backfilling missing delete_at values has to read
|
||||
|
||||
@@ -50,12 +50,14 @@ def test_persona_import_real_browser_session(initialized_archive, tmp_path, http
|
||||
"""Import an actual Chromium-created session and archive its private page."""
|
||||
import hashlib
|
||||
import json
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from werkzeug.wrappers import Response
|
||||
from archivebox.tests.conftest import resolve_abxpkg_chrome_env
|
||||
|
||||
browser_env = resolve_abxpkg_chrome_env(tmp_path / "lib")
|
||||
browser_env["ABXPKG_LIB_DIR"] = str(tmp_path / "lib")
|
||||
source = tmp_path / "browser"
|
||||
httpserver.expect_request("/login").respond_with_data(
|
||||
'<html><title>Signed in</title><script>localStorage.setItem("persona-preference", "forest-green")</script>Signed in</html>',
|
||||
@@ -90,6 +92,7 @@ const puppeteer = require(process.argv[1]);
|
||||
const page = await browser.newPage();
|
||||
await page.goto(process.argv[4]);
|
||||
console.log(await page.title());
|
||||
console.log(await page.evaluate(() => localStorage.getItem('persona-preference')));
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
""",
|
||||
@@ -104,6 +107,7 @@ const puppeteer = require(process.argv[1]);
|
||||
)
|
||||
assert seeded.returncode == 0, seeded.stderr
|
||||
assert "Signed in" in seeded.stdout
|
||||
assert "forest-green" in seeded.stdout
|
||||
preferences = source / "Profile 2" / "Preferences"
|
||||
original_preferences = preferences.read_bytes()
|
||||
|
||||
@@ -161,6 +165,9 @@ const puppeteer = require(process.argv[1]);
|
||||
env={**browser_env, "CHROME_SANDBOX": "false"},
|
||||
)
|
||||
assert archived.returncode == 0, archived.stderr
|
||||
launch_commands = list((initialized_archive / "archive" / "users").glob("*/crawls/**/chrome/cmd.sh"))
|
||||
assert len(launch_commands) == 1
|
||||
assert shlex.split(launch_commands[0].read_text().splitlines()[1])[0] == browser_env["CHROME_BINARY"]
|
||||
outputs = list((initialized_archive / "archive" / "users").glob("*/snapshots/**/dom/output.html"))
|
||||
assert len(outputs) == 1
|
||||
html = outputs[0].read_text()
|
||||
|
||||
@@ -95,3 +95,26 @@ def test_resolving_scoped_config_does_not_mutate_process_environment(tmp_path):
|
||||
os.environ.pop("ABXPKG_LIB_DIR", None)
|
||||
else:
|
||||
os.environ["ABXPKG_LIB_DIR"] = previous_lib_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["COOKIES_FILE", "AUTH_STORAGE_FILE", "WGET_COOKIES_FILE", "YTDLP_COOKIES_FILE", "GALLERYDL_COOKIES_FILE"])
|
||||
def test_cookie_paths_remain_collection_relative_after_runtime_overlays(key, tmp_path):
|
||||
from archivebox.config.common import ArchiveBoxConfig
|
||||
|
||||
config = ArchiveBoxConfig(**{key: "auth/cookies.txt"})
|
||||
assert str(config[key]) == str(CONSTANTS.DATA_DIR / "auth/cookies.txt")
|
||||
runtime = config.for_crawl_runtime(runtime_overrides={key: "other/cookies.txt"})
|
||||
assert runtime[key] == str(CONSTANTS.DATA_DIR / "other/cookies.txt")
|
||||
absolute = tmp_path / "absolute-cookies.txt"
|
||||
config = ArchiveBoxConfig(**{key: str(absolute)})
|
||||
assert str(config[key]) == str(absolute)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["COOKIES_FILE", "AUTH_STORAGE_FILE"])
|
||||
@pytest.mark.parametrize("value", ["", " ", None])
|
||||
def test_empty_cookie_path_disables_import(key, value):
|
||||
from archivebox.config.common import ArchiveBoxConfig
|
||||
|
||||
config = ArchiveBoxConfig(**{key: value})
|
||||
assert config[key] is None
|
||||
assert key not in config.for_crawl_runtime()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Regression coverage for collection cookie paths and config edits on upgrade."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from archivebox.config.configset import read_ini_config
|
||||
from archivebox.tests.conftest import run_archivebox_cmd
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cookie_file_exists", [True, False])
|
||||
def test_add_with_collection_relative_cookies(initialized_archive, httpserver, cookie_file_exists):
|
||||
"""Real crawl hooks must read cookies from the collection, not their cwd."""
|
||||
if cookie_file_exists:
|
||||
(initialized_archive / "cookies.txt").write_text(
|
||||
"# Netscape HTTP Cookie File\n127.0.0.1\tFALSE\t/\tFALSE\t2147483647\tissue1896\timported\n",
|
||||
)
|
||||
httpserver.expect_request("/").respond_with_data(
|
||||
"<html><title>Issue 1896</title><body><p>Collection cookie import regression: "
|
||||
"this page must still be archived when the optional cookie file is absent.</p>"
|
||||
'<output id="cookies"></output><script>document.getElementById("cookies").textContent = document.cookie;</script></body></html>',
|
||||
content_type="text/html",
|
||||
)
|
||||
run_archivebox_cmd(
|
||||
["config", "--set", "COOKIES_FILE=cookies.txt"],
|
||||
cwd=initialized_archive,
|
||||
check=True,
|
||||
)
|
||||
result = run_archivebox_cmd(
|
||||
["add", "--plugins=dom", httpserver.url_for("/").replace("localhost", "127.0.0.1")],
|
||||
cwd=initialized_archive,
|
||||
timeout=180,
|
||||
env={"CHROME_SANDBOX": "false"},
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
outputs = list((initialized_archive / "archive" / "users").glob("*/snapshots/**/dom/output.html"))
|
||||
assert len(outputs) == 1, result.stdout + result.stderr
|
||||
html = outputs[0].read_text()
|
||||
assert ("issue1896=imported" in html) == cookie_file_exists
|
||||
launch_logs = list((initialized_archive / "archive" / "users").glob("*/crawls/**/chrome/*.stderr.log"))
|
||||
logs = "\n".join(log.read_text() for log in launch_logs)
|
||||
if cookie_file_exists:
|
||||
assert f"from {initialized_archive / 'cookies.txt'}" in logs
|
||||
else:
|
||||
assert "Cookies file not found" in logs
|
||||
assert "continuing without cookie import" in logs
|
||||
with closing(sqlite3.connect(initialized_archive / "index.sqlite3")) as db:
|
||||
assert db.execute("SELECT status FROM core_snapshot").fetchall() == [("sealed",)]
|
||||
assert db.execute("SELECT status FROM core_archiveresult WHERE plugin='dom'").fetchall() == [("succeeded",)]
|
||||
hook_rows = db.execute("SELECT env, status, exit_code FROM machine_process WHERE process_type='hook'").fetchall()
|
||||
assert hook_rows
|
||||
for env, status, exit_code in hook_rows:
|
||||
assert json.loads(env)["COOKIES_FILE"] == str(initialized_archive / "cookies.txt")
|
||||
assert (status, exit_code) == ("exited", 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled_key", ["USE_CHROME", "CHROME_ENABLED"])
|
||||
@pytest.mark.parametrize("startup_save", ["binary_sanitation", "host_refresh"])
|
||||
def test_init_preserves_edited_use_chrome_before_startup_save(initialized_archive, enabled_key, startup_save):
|
||||
"""Startup saves must not overwrite a newer file before config sync."""
|
||||
config_path = initialized_archive / "ArchiveBox.conf"
|
||||
config_path.write_text(config_path.read_text() + f"\n[SERVER_CONFIG]\n{enabled_key} = True\n")
|
||||
run_archivebox_cmd(
|
||||
["config", "--set", f"WGET_BINARY={initialized_archive / 'removed-wget'}"]
|
||||
if startup_save == "binary_sanitation"
|
||||
else ["config", "--set", "TIMEOUT=37"],
|
||||
cwd=initialized_archive,
|
||||
check=True,
|
||||
)
|
||||
if startup_save == "host_refresh":
|
||||
with closing(sqlite3.connect(initialized_archive / "index.sqlite3")) as db:
|
||||
db.execute("UPDATE machine_machine SET modified_at=?", [(datetime.now(timezone.utc) - timedelta(days=8)).isoformat()])
|
||||
db.commit()
|
||||
config_path.write_text(config_path.read_text().replace(f"{enabled_key} = True", f"{enabled_key} = False"))
|
||||
assert read_ini_config(config_path)[enabled_key] == "False"
|
||||
run_archivebox_cmd(["init"], cwd=initialized_archive, timeout=120, check=True)
|
||||
assert read_ini_config(config_path)[enabled_key] == "False"
|
||||
result = run_archivebox_cmd(["config", "--get", "CHROME_ENABLED"], cwd=initialized_archive)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "CHROME_ENABLED = false" in result.stdout
|
||||
@@ -26,6 +26,65 @@ pytestmark = pytest.mark.django_db(transaction=True)
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_bulk_snapshot_delete_keeps_rows_until_worker_verifies_files(client, admin_user, crawl):
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.services.runner import run_pending_crawls
|
||||
|
||||
snapshots = [Snapshot.objects.create(url=f"https://example.com/delete/{i}", crawl=crawl) for i in range(2)]
|
||||
output_dirs = [snapshot.output_dir for snapshot in snapshots]
|
||||
for snapshot, output_dir in zip(snapshots, output_dirs):
|
||||
(output_dir / "wget").mkdir(parents=True, exist_ok=True)
|
||||
(output_dir / "wget" / "index.html").write_text("real saved output")
|
||||
ArchiveResult.objects.create(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget", status="succeeded")
|
||||
ids = [snapshot.pk for snapshot in snapshots]
|
||||
client.force_login(admin_user)
|
||||
response = client.post(
|
||||
reverse("admin:core_snapshot_changelist"),
|
||||
{"action": "delete_snapshots", ACTION_CHECKBOX_NAME: [str(pk) for pk in ids]},
|
||||
HTTP_HOST=ADMIN_TEST_HOST,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert Snapshot.objects.filter(pk__in=ids, status="deleting").count() == 2
|
||||
assert ArchiveResult.objects.filter(snapshot_id__in=ids).count() == 2
|
||||
assert all((path / "wget" / "index.html").is_file() for path in output_dirs)
|
||||
pending = client.get(reverse("admin:core_snapshot_changelist"), HTTP_HOST=ADMIN_TEST_HOST)
|
||||
assert pending.status_code == 200
|
||||
assert "Deleting" in pending.content.decode()
|
||||
run_pending_crawls(maintenance_only=True)
|
||||
assert not Snapshot.objects.filter(pk__in=ids).exists()
|
||||
assert not ArchiveResult.objects.filter(snapshot_id__in=ids).exists()
|
||||
assert not any(path.exists() for path in output_dirs)
|
||||
|
||||
|
||||
def test_snapshot_delete_preserves_rows_when_real_filesystem_cleanup_fails(snapshot):
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
output_dir = snapshot.output_dir
|
||||
protected = output_dir / "wget"
|
||||
protected.mkdir(parents=True, exist_ok=True)
|
||||
saved_file = protected / "index.html"
|
||||
saved_file.write_text("must not lose the row before this file is removed")
|
||||
result = ArchiveResult.objects.create(snapshot=snapshot, plugin="wget", hook_name="on_Snapshot__06_wget", status="succeeded")
|
||||
snapshot_id = snapshot.pk
|
||||
protected.chmod(0o500)
|
||||
try:
|
||||
with pytest.raises(PermissionError):
|
||||
Snapshot.objects.filter(pk=snapshot_id).delete()
|
||||
assert Snapshot.objects.filter(pk=snapshot_id, status="deleting").exists()
|
||||
assert ArchiveResult.objects.filter(pk=result.pk).exists()
|
||||
assert saved_file.is_file()
|
||||
from archivebox.services.runner import run_pending_crawls
|
||||
|
||||
run_pending_crawls(maintenance_only=True)
|
||||
assert Snapshot.objects.filter(pk=snapshot_id, status="deleting").exists()
|
||||
assert saved_file.is_file()
|
||||
finally:
|
||||
protected.chmod(0o700)
|
||||
run_pending_crawls(maintenance_only=True)
|
||||
assert not Snapshot.objects.filter(pk=snapshot_id).exists()
|
||||
assert not output_dir.exists()
|
||||
|
||||
|
||||
def test_failed_hook_partial_file_does_not_become_successful_card(snapshot, cached_abxpkg_lib_dir):
|
||||
partial = Path(snapshot.output_dir) / "title" / "partial.html"
|
||||
partial.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "archivebox",
|
||||
"version": "0.9.71",
|
||||
"version": "0.9.72rc5",
|
||||
"repository": "github:ArchiveBox/ArchiveBox",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "archivebox"
|
||||
version = "0.9.71"
|
||||
version = "0.9.72rc5"
|
||||
requires-python = ">=3.13"
|
||||
description = "Self-hosted internet archiving solution."
|
||||
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
|
||||
@@ -343,7 +343,7 @@ Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
|
||||
|
||||
|
||||
[tool.bumpver]
|
||||
current_version = "v0.9.71"
|
||||
current_version = "v0.9.72rc5"
|
||||
version_pattern = "vMAJOR.MINOR.PATCH[PYTAGNUM]"
|
||||
commit_message = "bump version {old_version} -> {new_version}"
|
||||
tag_message = "{new_version}"
|
||||
|
||||
Reference in new issue
Block a user