Compare commits

..
Author SHA1 Message Date
ArchiveBox Release Bot cd2815d205 Bump release version to 0.9.72rc5 2026-09-30 04:46:44 +00:00
Nick Sweeting bf4c1cfa20 Preserve collection auth paths and explicit runtime browser settings
Resolve relative cookie files before hooks change cwd, reconcile file edits before machine startup saves, and prevent stale cached binaries overriding runtime selections. Cover issue #1896 and real persona localStorage capture through CLI regressions.
2026-09-29 21:45:50 -07:00
ArchiveBox Release Bot f1f21aaaef Bump release version to 0.9.72rc4 2026-09-30 04:40:27 +00:00
Nick Sweeting 29ab6fd05d Keep archive storage transparent to ArchiveBox deletion 2026-09-29 21:38:46 -07:00
ArchiveBox Release Bot 94e7906893 Bump release version to 0.9.72rc3 2026-09-30 04:19:52 +00:00
Nick Sweeting b36616cd5e Merge remote-tracking branch 'origin/dev' into dev 2026-09-29 21:18:59 -07:00
Nick Sweeting be54377c67 Provision real rclone dependency for storage deletion regression 2026-09-29 21:18:58 -07:00
ArchiveBox Release Bot 26991ae950 Bump release version to 0.9.72rc2 2026-09-30 04:16:19 +00:00
Nick Sweeting 04bdde0711 Merge remote-tracking branch 'origin/dev' into dev 2026-09-29 21:14:45 -07:00
Nick Sweeting 5262df1bec Verify rclone backing objects before committing snapshot deletion 2026-09-29 21:14:23 -07:00
ArchiveBox Release Bot 20cb72a64b Bump release version to 0.9.72rc1 2026-09-30 03:41:14 +00:00
Nick Sweeting 04a01fd7b1 Queue snapshot deletion and verify files before deleting rows 2026-09-29 20:40:35 -07:00
github-actions[bot] 09ec9e1f03 Merge tested stable release into dev 2026-09-28 22:14:05 +00:00
ArchiveBox Release Bot c6542d791a Bump release version to 0.9.71rc62 2026-09-28 21:46:46 +00:00
16 changed files with 357 additions and 57 deletions

No files matched your search

+11 -2
View File
@@ -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)
+2 -3
View File
@@ -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
+33
View File
@@ -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")
+11 -17
View File
@@ -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,
),
),
]
+57 -1
View File
@@ -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."""
+1 -1
View File
@@ -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:
+28 -28
View File
@@ -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
+10 -1
View File
@@ -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
+7
View File
@@ -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()
+23
View File
@@ -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()
+83
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.71",
"version": "0.9.72rc5",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -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}"
Generated
+1 -1
View File
@@ -127,7 +127,7 @@ wheels = [
[[package]]
name = "archivebox"
version = "0.9.71"
version = "0.9.72rc5"
source = { editable = "." }
dependencies = [
{ name = "abx-dl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },