Fix container sync races that leaked orphan blobs; add a system-prune CLI command

sync_workspace (user-triggered) and the reconciler's sync_bidirectional_sync
could run concurrently for the same project, and store_upload's read-then-
write on a changed path meant two racing imports each wrote their own blob
while only one ever got referenced - the loser leaked forever. Combined with
no build-artifact exclusion, an actively-compiling workspace hit this
constantly and leaked 5.9M orphan blobs (~96GB) in production before it was
caught.

Closes it at the root: api._sync_dir_bidirectional_locked serializes both
call sites per-project (non-blocking - a project already mid-sync is simply
skipped until the next tick), and IMPORT_SKIP_NAMES/IMPORT_SKIP_EXTENSIONS
keep build output (build/, dist/, *.o, *.pyc, ...) out of the walk entirely.

Recovering what already leaked is a separate concern: a new CLI subcommand
(plus matching make targets) sweeps soft-deleted attachment/project-file
blobs and any blob with zero DB reference at all, plus orphaned container
workspace directories. run_maintenance_cleanup.sh wraps the existing
prune/clear commands for routine disk upkeep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
This commit is contained in:
2026-09-08 03:43:49 +02:00
co-authored by Claude Sonnet 5
parent 85bd8fad47
commit 7880bf4b31
19 changed files with 1068 additions and 19 deletions
+1
View File
@@ -58,6 +58,7 @@ devplace token prune # soft-delete all expired access tokens
devplace news clear # delete all news rows devplace news clear # delete all news rows
devplace news sanitize # strip HTML from news descriptions/content devplace news sanitize # strip HTML from news descriptions/content
devplace attachments prune # remove orphan attachment records/files devplace attachments prune # remove orphan attachment records/files
devplace system prune [--dry-run] # safe but aggressive: purges soft-deleted attachment/project-file blobs, sweeps blob files with zero DB reference at all (e.g. left by an interrupted/racing sync), and GCs orphaned container workspace dirs
devplace devii reset-quota <username> # reset one user's rolling 24h AI quota devplace devii reset-quota <username> # reset one user's rolling 24h AI quota
devplace devii reset-quota --guests # reset every guest quota devplace devii reset-quota --guests # reset every guest quota
devplace devii reset-quota --all # reset every quota (users and guests) devplace devii reset-quota --all # reset every quota (users and guests)
+10 -1
View File
@@ -18,7 +18,7 @@ BOOTSTRAP_PYTHON := $(shell command -v python3 2>/dev/null || command -v python
PYTHONDONTWRITEBYTECODE := 1 PYTHONDONTWRITEBYTECODE := 1
export PYTHONDONTWRITEBYTECODE export PYTHONDONTWRITEBYTECODE
.PHONY: venv install dev prod clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless .PHONY: venv install dev prod clean tree tree-loc zip test test-headed test-unit test-api test-e2e test-fast test-failed test-first-failure test-slowest test-cache-clean coverage coverage-headed coverage-html locust locust-headless prune prune-dry-run
$(PYTHON): $(PYTHON):
@test -n "$(BOOTSTRAP_PYTHON)" || { echo "python3 is required to create $(VENV)"; exit 1; } @test -n "$(BOOTSTRAP_PYTHON)" || { echo "python3 is required to create $(VENV)"; exit 1; }
@@ -148,6 +148,15 @@ clean:
test-cache-clean: test-cache-clean:
rm -rf .pytest_cache rm -rf .pytest_cache
# Safe but aggressive disk-space cleanup, acting on the REAL data/devplace.db
# and data/ tree (never the test database) - see CLAUDE.md "devplace system
# prune". prune-dry-run reports what would be removed without deleting.
prune: $(VENV_STAMP)
$(PYTHON) -m devplacepy.cli system prune
prune-dry-run: $(VENV_STAMP)
$(PYTHON) -m devplacepy.cli system prune --dry-run
# Container Manager works out of the box: the overlay installs the docker CLI in # Container Manager works out of the box: the overlay installs the docker CLI in
# the image and mounts the host socket. DOCKER_GID is read straight from the # the image and mounts the host socket. DOCKER_GID is read straight from the
# socket so the UID-1000 app can use it; the data dir is the project's own data/ # socket so the UID-1000 app can use it; the data dir is the project's own data/
+68
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import ipaddress import ipaddress
import logging import logging
import os
import socket import socket
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -637,6 +638,73 @@ def restore_attachment(uid):
return True return True
def purge_soft_deleted_attachments(*, dry_run: bool = False) -> tuple[int, int]:
if "attachments" not in db.tables:
return 0, 0
table = get_table("attachments")
rows = list(table.find(table.table.columns.deleted_at.isnot(None)))
removed = 0
freed = 0
for row in rows:
directory = row.get("directory")
stored_name = row.get("stored_name")
if directory and stored_name:
path = ATTACHMENTS_DIR / directory / stored_name
try:
freed += path.stat().st_size
except OSError:
pass
for thumb in (ATTACHMENTS_DIR / directory).glob(
f"{Path(stored_name).stem}_thumb.*"
):
try:
freed += thumb.stat().st_size
except OSError:
pass
if not dry_run:
_unlink_attachment_files(row)
if not dry_run:
table.delete(id=row["id"])
removed += 1
return removed, freed
def sweep_orphan_attachment_blobs(*, dry_run: bool = False) -> tuple[int, int]:
if not ATTACHMENTS_DIR.exists():
return 0, 0
referenced_stems = set()
if "attachments" in db.tables:
for row in get_table("attachments").find():
directory = row.get("directory")
stored_name = row.get("stored_name")
if directory and stored_name:
referenced_stems.add((directory, Path(stored_name).stem))
removed = 0
freed = 0
base = str(ATTACHMENTS_DIR)
for root, _dirs, files in os.walk(base):
directory = os.path.relpath(root, base)
for name in files:
stem = Path(name).stem
if stem.endswith("_thumb"):
stem = stem[: -len("_thumb")]
if (directory, stem) in referenced_stems:
continue
file_path = os.path.join(root, name)
try:
size = os.path.getsize(file_path)
except OSError:
continue
if not dry_run:
try:
os.unlink(file_path)
except OSError:
continue
removed += 1
freed += size
return removed, freed
def soft_delete_target_attachments(target_type, target_uid, deleted_by): def soft_delete_target_attachments(target_type, target_uid, deleted_by):
stamp = datetime.now(timezone.utc).isoformat() stamp = datetime.now(timezone.utc).isoformat()
for row in get_table("attachments").find( for row in get_table("attachments").find(
+2
View File
@@ -45,6 +45,7 @@ from devplacepy.cli.containers import (
) )
from devplacepy.cli.quiz import cmd_quiz_prune from devplacepy.cli.quiz import cmd_quiz_prune
from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data from devplacepy.cli.migrate import cmd_emoji_sync, cmd_migrate_data
from devplacepy.cli.system import cmd_system_prune
__all__ = [ __all__ = [
"main", "main",
@@ -91,4 +92,5 @@ __all__ = [
"cmd_quiz_prune", "cmd_quiz_prune",
"cmd_emoji_sync", "cmd_emoji_sync",
"cmd_migrate_data", "cmd_migrate_data",
"cmd_system_prune",
] ]
+1 -11
View File
@@ -67,19 +67,9 @@ def cmd_containers_prune_builds(args):
def cmd_containers_gc_workspaces(args): def cmd_containers_gc_workspaces(args):
import shutil
from pathlib import Path
from devplacepy import config
from devplacepy.services.containers import store from devplacepy.services.containers import store
active = {inst["project_uid"] for inst in store.all_instances()} removed = store.gc_workspaces()
base = Path(config.CONTAINER_WORKSPACES_DIR)
removed = 0
if base.is_dir():
for child in base.iterdir():
if child.is_dir() and child.name not in active:
shutil.rmtree(child, ignore_errors=True)
removed += 1
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed}) _audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
print( print(
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}" f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
+2
View File
@@ -17,6 +17,7 @@ from devplacepy.cli.game import register_game
from devplacepy.cli.quiz import register_quiz from devplacepy.cli.quiz import register_quiz
from devplacepy.cli.gateway import register_gateway from devplacepy.cli.gateway import register_gateway
from devplacepy.cli.messaging import register_messaging from devplacepy.cli.messaging import register_messaging
from devplacepy.cli.system import register_system
def build_parser(): def build_parser():
@@ -38,6 +39,7 @@ def build_parser():
register_gateway(sub) register_gateway(sub)
register_messaging(sub) register_messaging(sub)
register_accounts(sub) register_accounts(sub)
register_system(sub)
return parser return parser
+92
View File
@@ -0,0 +1,92 @@
# retoor <retoor@molodetz.nl>
from devplacepy.cli._shared import _audit_cli
def _human(n):
n = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}PB"
def cmd_system_prune(args):
from devplacepy.attachments import (
purge_soft_deleted_attachments,
sweep_orphan_attachment_blobs,
)
from devplacepy.project_files import (
purge_soft_deleted_project_files,
sweep_orphan_project_file_blobs,
)
from devplacepy.services.containers import store as container_store
dry_run = bool(args.dry_run)
verb = "Would remove" if dry_run else "Removed"
totals = {"items": 0, "freed": 0}
def report(name, count, freed):
totals["items"] += count
totals["freed"] += freed
print(f" {name}: {verb.lower()} {count} item(s), {_human(freed)}")
print("System prune - safe but aggressive disk-space cleanup")
print(
"Removes only content with zero live reference: soft-deleted attachment/"
"\nproject-file blobs, and blob files with no matching database row at all"
"\n(orphans, e.g. left behind by an interrupted or racing sync). Never"
"\ntouches live content."
)
if dry_run:
print("DRY RUN: nothing will be deleted.")
print()
count, freed = purge_soft_deleted_attachments(dry_run=dry_run)
report("soft-deleted attachments", count, freed)
count, freed = purge_soft_deleted_project_files(dry_run=dry_run)
report("soft-deleted project files", count, freed)
count, freed = sweep_orphan_attachment_blobs(dry_run=dry_run)
report("orphan attachment blobs", count, freed)
count, freed = sweep_orphan_project_file_blobs(dry_run=dry_run)
report("orphan project-file blobs", count, freed)
removed = container_store.gc_workspaces(dry_run=dry_run)
totals["items"] += removed
plural = "y" if removed == 1 else "ies"
print(f" orphaned container workspaces: {verb.lower()} {removed} director{plural}")
print()
suffix = " (estimate)" if dry_run else ""
print(f"{verb} {totals['items']} item(s) total, {_human(totals['freed'])} of disk{suffix}")
if not dry_run:
_audit_cli(
"cli.system.prune",
f"CLI system prune removed {totals['items']} item(s), freed {_human(totals['freed'])}",
metadata={"items": totals["items"], "bytes_freed": totals["freed"]},
)
def register_system(subparsers):
system = subparsers.add_parser("system", help="Cross-cutting system maintenance")
system_sub = system.add_subparsers(title="action", dest="action")
prune = system_sub.add_parser(
"prune",
help=(
"Safe but aggressive disk-space cleanup: purges soft-deleted "
"attachment/project-file blobs and any blob with zero database "
"reference at all, plus orphaned container workspace directories"
),
)
prune.add_argument(
"--dry-run",
action="store_true",
help="Report what would be removed without deleting anything",
)
prune.set_defaults(func=cmd_system_prune)
+6 -3
View File
@@ -18,6 +18,7 @@ def paginate(
order=None, order=None,
cursor_field="created_at", cursor_field="created_at",
viewer_uid=None, viewer_uid=None,
limit=PAGE_SIZE,
**filters, **filters,
): ):
order = order or ["-" + cursor_field] order = order or ["-" + cursor_field]
@@ -30,9 +31,9 @@ def paginate(
clauses.append(table.table.columns.user_uid.notin_(blocked)) clauses.append(table.table.columns.user_uid.notin_(blocked))
if before: if before:
clauses.append(table.table.columns[cursor_field] < before) clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1)) rows = list(table.find(*clauses, **filters, order_by=order, _limit=limit + 1))
has_more = len(rows) > PAGE_SIZE has_more = len(rows) > limit
rows = rows[:PAGE_SIZE] rows = rows[:limit]
next_cursor = rows[-1][cursor_field] if has_more and rows else None next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor return rows, next_cursor
@@ -64,6 +65,7 @@ def paginate_diverse(
cursor_field="created_at", cursor_field="created_at",
uid_key="user_uid", uid_key="user_uid",
viewer_uid=None, viewer_uid=None,
limit=PAGE_SIZE,
**filters, **filters,
): ):
rows, next_cursor = paginate( rows, next_cursor = paginate(
@@ -73,6 +75,7 @@ def paginate_diverse(
order=order, order=order,
cursor_field=cursor_field, cursor_field=cursor_field,
viewer_uid=viewer_uid, viewer_uid=viewer_uid,
limit=limit,
**filters, **filters,
) )
return interleave_by_author(rows, uid_key=uid_key), next_cursor return interleave_by_author(rows, uid_key=uid_key), next_cursor
+90 -1
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import logging import logging
import os
import shutil import shutil
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -554,7 +555,7 @@ def _walk_workspace_files(src, skip):
for root, dirs, files in src.walk(): for root, dirs, files in src.walk():
dirs[:] = [d for d in sorted(dirs) if d not in skip] dirs[:] = [d for d in sorted(dirs) if d not in skip]
for name in sorted(files): for name in sorted(files):
if name in skip: if name in skip or Path(name).suffix in IMPORT_SKIP_EXTENSIONS:
continue continue
full = Path(root) / name full = Path(root) / name
if full.is_symlink() or not full.is_file(): if full.is_symlink() or not full.is_file():
@@ -780,6 +781,36 @@ IMPORT_SKIP_NAMES = {
".DS_Store", ".DS_Store",
".idea", ".idea",
".cache", ".cache",
"build",
"dist",
"target",
"out",
"bin",
"obj",
".next",
".nuxt",
".gradle",
".tox",
"cmake-build-debug",
"cmake-build-release",
}
# Compiled/build-artifact extensions, regenerated wholesale on every build -
# never worth importing/syncing regardless of which directory they land in
# (unlike IMPORT_SKIP_NAMES, matched by suffix rather than exact name; see
# _walk_workspace_files). A missing exclusion here is what let an unlocked
# concurrent sync (see api._sync_dir_bidirectional_locked) leak millions of
# orphaned blobs from an actively-compiling container workspace.
IMPORT_SKIP_EXTENSIONS = {
".o",
".obj",
".pyc",
".pyo",
".class",
".so",
".dylib",
".dll",
".a",
".exe",
} }
SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | { SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {
".devplace_boot.py", ".devplace_boot.py",
@@ -998,3 +1029,61 @@ def delete_all_project_files(project_uid: str) -> None:
if row.get("is_binary"): if row.get("is_binary"):
_unlink_blob(row) _unlink_blob(row)
_table().delete(project_uid=project_uid) _table().delete(project_uid=project_uid)
def purge_soft_deleted_project_files(*, dry_run: bool = False) -> tuple[int, int]:
if "project_files" not in db.tables:
return 0, 0
table = _table()
rows = list(table.find(table.table.columns.deleted_at.isnot(None), is_binary=1))
removed = 0
freed = 0
for row in rows:
directory = row.get("directory")
stored_name = row.get("stored_name")
if directory and stored_name:
path = PROJECT_FILES_DIR / directory / stored_name
try:
freed += path.stat().st_size
except OSError:
pass
else:
if not dry_run:
_unlink_blob(row)
if not dry_run:
table.delete(id=row["id"])
removed += 1
return removed, freed
def sweep_orphan_project_file_blobs(*, dry_run: bool = False) -> tuple[int, int]:
if not PROJECT_FILES_DIR.exists():
return 0, 0
referenced = set()
if "project_files" in db.tables:
for row in _table().find(is_binary=1):
directory = row.get("directory")
stored_name = row.get("stored_name")
if directory and stored_name:
referenced.add((directory, stored_name))
removed = 0
freed = 0
base = str(PROJECT_FILES_DIR)
for root, _dirs, files in os.walk(base):
directory = os.path.relpath(root, base)
for name in files:
if (directory, name) in referenced:
continue
file_path = os.path.join(root, name)
try:
size = os.path.getsize(file_path)
except OSError:
continue
if not dry_run:
try:
os.unlink(file_path)
except OSError:
continue
removed += 1
freed += size
return removed, freed
+40
View File
@@ -408,6 +408,46 @@ gated on `time.monotonic()` independent of the 5s reconcile tick). The per-insta
boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they
never round-trip into the project, and therefore never enter the manifest either. never round-trip into the project, and therefore never enter the manifest either.
**Both call sites are unsynchronized, and that used to leak millions of orphan blobs
(load-bearing, incident-derived).** `sync_workspace` (user-triggered) and
`sync_bidirectional_sync` (the reconciler tick) can run for the SAME project concurrently
whenever a sync outlasts the reconcile interval - trivial for any workspace that is
actively compiling. `project_files.store_upload` (the import path for a changed file)
writes a NEW blob under a fresh uuid, then updates the existing row to point at it; two
racing imports of the same changed path each write their own blob and both update the
same row (last writer wins), so the loser's freshly-written blob is referenced by nothing
- an orphan on every race, not just on a crash. Combined with no build-artifact exclusion,
an actively-compiling workspace regenerates thousands of files with fresh mtimes on every
build, which is exactly the high-churn pattern that maximizes how often the race fires;
sustained over time this leaked **5.9 million orphan blobs (~96GB)** in production, discovered
only when the host disk hit 100% full. Both are now closed at their root:
- **`api._sync_dir_bidirectional_locked`** wraps `project_files.sync_dir_bidirectional`
behind a per-`project_uid` `threading.Lock` (`api._sync_lock_for`, a lazily-created
registry - both `sync_workspace` and `sync_bidirectional_sync` call it instead of the
raw function). The acquire is **non-blocking**: a second sync for a project already mid-sync
is skipped outright (returns the zero-counts dict), never queued or awaited. This runs on
an `asyncio.to_thread` worker in both call sites, so blocking would tie up a thread pool
slot for no reason - a skipped project is simply picked up on the next tick or the user's
next explicit sync.
- **`project_files.IMPORT_SKIP_NAMES`** gained common build-output directory names (`build`,
`dist`, `target`, `out`, `bin`, `obj`, `.next`, `.nuxt`, `.gradle`, `.tox`,
`cmake-build-debug`, `cmake-build-release`) and a NEW parallel set,
**`IMPORT_SKIP_EXTENSIONS`** (`.o`, `.obj`, `.pyc`, `.pyo`, `.class`, `.so`, `.dylib`,
`.dll`, `.a`, `.exe`), matched by suffix in `_walk_workspace_files` rather than exact
name (`IMPORT_SKIP_NAMES`/`SYNC_SKIP_NAMES` are exact-name-only sets, which cannot express
"any file ending in `.o`"). Both apply to every `_walk_workspace_files` caller (`import_from_dir`
AND the sync path via `SYNC_SKIP_NAMES = IMPORT_SKIP_NAMES | {...}`), so compiled/build
artifacts are never imported into a project regardless of entry point, not just never synced.
Neither fix requires (or should ever regress into) a redesign of the manifest/reconciliation
model above - the lock only prevents two runs from touching the same project's `project_files`
rows at once, and the skip sets only shrink what `_walk_workspace_files` yields. Recovering
already-leaked blobs is a separate, data-layer concern: see `devplace system prune`
(`cli/system.py`) and `attachments.sweep_orphan_attachment_blobs`/
`project_files.sweep_orphan_project_file_blobs`, which sweep any blob with zero database
reference at all, regardless of what leaked it.
## Run-as user = identity + API key ONLY (load-bearing constraint) ## Run-as user = identity + API key ONLY (load-bearing constraint)
An instance's `run_as_uid` column selects WHICH DevPlace user's identity and `api_key` are injected (`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`), resolved in `api.pravda_env` ahead of the `created_by`/`owner_uid` fallback chain. It does **NOT** change the container OS user, which is ALWAYS `pravda` (uid 1000) - required for the bind-mounted `/app` (DooD uid maps 1:1 to host). Validate it against an existing user via `api.validate_run_as`. An instance's `run_as_uid` column selects WHICH DevPlace user's identity and `api_key` are injected (`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`), resolved in `api.pravda_env` ahead of the `created_by`/`owner_uid` fallback chain. It does **NOT** change the container OS user, which is ALWAYS `pravda` (uid 1000) - required for the bind-mounted `/app` (DooD uid maps 1:1 to host). Validate it against an existing user via `api.validate_run_as`.
+40 -2
View File
@@ -5,6 +5,7 @@ import json
import re import re
import secrets import secrets
import socket import socket
import threading
from pathlib import Path from pathlib import Path
from devplacepy import config, project_files, stealth from devplacepy import config, project_files, stealth
@@ -611,12 +612,49 @@ def run_spec_for(instance: dict, image_tag: str) -> RunSpec:
) )
_SYNC_LOCKS: dict[str, threading.Lock] = {}
_SYNC_LOCKS_GUARD = threading.Lock()
_SYNC_EMPTY_COUNTS = {
"exported": 0,
"imported": 0,
"deleted_in_project": 0,
"deleted_in_workspace": 0,
}
def _sync_lock_for(project_uid: str) -> threading.Lock:
with _SYNC_LOCKS_GUARD:
lock = _SYNC_LOCKS.get(project_uid)
if lock is None:
lock = threading.Lock()
_SYNC_LOCKS[project_uid] = lock
return lock
def _sync_dir_bidirectional_locked(project_uid: str, workspace, user: dict) -> dict:
# The user-triggered sync and the reconciler's periodic sync both call
# this, unsynchronized. Without this lock, two overlapping runs for the
# SAME project race sync_dir_bidirectional -> store_upload: each writes
# its own fresh blob for a changed file, both update the same DB row
# (last writer wins), and the loser's blob is now referenced by
# nothing - an orphan on every race, forever. A non-blocking skip (never
# a blocking wait) is deliberate: this runs on an asyncio.to_thread
# worker, and a project mid-sync simply gets picked up on the next tick.
lock = _sync_lock_for(project_uid)
if not lock.acquire(blocking=False):
return dict(_SYNC_EMPTY_COUNTS)
try:
return project_files.sync_dir_bidirectional(project_uid, workspace, user)
finally:
lock.release()
async def sync_workspace(instance: dict, user: dict) -> dict: async def sync_workspace(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir") workspace = instance.get("workspace_dir")
if not workspace: if not workspace:
raise ContainerError("instance has no workspace") raise ContainerError("instance has no workspace")
counts = await asyncio.to_thread( counts = await asyncio.to_thread(
project_files.sync_dir_bidirectional, instance["project_uid"], workspace, user _sync_dir_bidirectional_locked, instance["project_uid"], workspace, user
) )
store.record_event( store.record_event(
instance, instance,
@@ -632,7 +670,7 @@ def sync_bidirectional_sync(instance: dict, user: dict) -> dict:
workspace = instance.get("workspace_dir") workspace = instance.get("workspace_dir")
if not workspace: if not workspace:
return {"exported": 0, "imported": 0} return {"exported": 0, "imported": 0}
counts = project_files.sync_dir_bidirectional( counts = _sync_dir_bidirectional_locked(
instance["project_uid"], workspace, user instance["project_uid"], workspace, user
) )
if counts["exported"] or counts["imported"]: if counts["exported"] or counts["imported"]:
+19
View File
@@ -92,6 +92,25 @@ def all_instances() -> list:
return list(get_table("instances").find(deleted_at=None)) return list(get_table("instances").find(deleted_at=None))
def gc_workspaces(*, dry_run: bool = False) -> int:
import shutil
from pathlib import Path
from devplacepy import config
active = {inst["project_uid"] for inst in all_instances()}
base = Path(config.CONTAINER_WORKSPACES_DIR)
if not base.is_dir():
return 0
removed = 0
for child in base.iterdir():
if child.is_dir() and child.name not in active:
if not dry_run:
shutil.rmtree(child, ignore_errors=True)
removed += 1
return removed
def find_instance_by_ingress(slug: str): def find_instance_by_ingress(slug: str):
if not slug or not _exists("instances"): if not slug or not _exists("instances"):
return None return None
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "devplacepy" name = "devplacepy"
version = "1.0.3" version = "1.0.4"
description = "DevPlace - The Developer Social Network" description = "DevPlace - The Developer Social Network"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# retoor <retoor@molodetz.nl>
set -uo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null || true
REPO_DIR="${DEVPLACE_REPO_DIR:-/home/retoor/projects/devplacepy}"
cd "$REPO_DIR" || { echo "Cannot cd into $REPO_DIR - set DEVPLACE_REPO_DIR"; exit 1; }
if [ -x "$REPO_DIR/.venv/bin/devplace" ]; then
DEVPLACE="$REPO_DIR/.venv/bin/devplace"
elif command -v devplace >/dev/null 2>&1; then
DEVPLACE="devplace"
else
echo "Cannot find the devplace binary (looked in $REPO_DIR/.venv/bin and PATH)."
echo "Run 'make install' once to create the .venv, or activate it yourself."
exit 1
fi
if [ -x "$REPO_DIR/.venv/bin/python" ]; then
PYTHON="$REPO_DIR/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PYTHON="python3"
else
echo "Cannot find a python interpreter (looked in $REPO_DIR/.venv/bin and PATH)."
exit 1
fi
DIRS=(
data/uploads
data/uploads/attachments
data/uploads/project_files
data/zips
data/zip_staging
data/fork_staging
data/seo_reports
data/deepsearch
data/isslop
data/container_workspaces
data/workspace_state
)
show_sizes() {
echo "--- $1 ---"
for d in "${DIRS[@]}"; do
if [ -d "$d" ]; then
printf " %-10s %s\n" "$(du -sh "$d" 2>/dev/null | cut -f1)" "$d"
else
printf " %-10s %s\n" "-" "$d (absent)"
fi
done
echo
}
FAILED=()
run() {
echo "=== devplace $* ==="
"$DEVPLACE" "$@"
local status=$?
if [ "$status" -ne 0 ]; then
echo "!!! FAILED (exit $status): devplace $*"
FAILED+=("devplace $*")
fi
echo
}
echo "########################################"
echo "# DevPlace maintenance cleanup"
echo "# backups and news are deliberately excluded"
echo "########################################"
echo
show_sizes "BEFORE"
run attachments prune
run zips prune
run zips clear
run forks prune
run forks clear
run seo prune
run seo clear
run seo-meta prune
run seo-meta clear
run deepsearch prune
run deepsearch clear
run isslop prune
run isslop clear
run quiz prune
run game market prune
run game steals prune
run messaging prune-tickets
run devii tasks prune
run accounts prune
run containers prune
run containers prune-builds
run containers gc-workspaces
show_sizes "AFTER"
echo "########################################"
echo "# Soft-deleted attachment / project-file blobs still on disk"
echo "# (READ-ONLY report - nothing below this line deletes anything;"
echo "# none of the commands above purge these, only the admin Trash"
echo "# 'Purge' button per-event, or accounts prune, do that today)"
echo "########################################"
"$PYTHON" - "$REPO_DIR" <<'PY'
import sqlite3
import sys
from pathlib import Path
repo_dir = Path(sys.argv[1])
data_dir = Path(__import__("os").environ.get("DEVPLACE_DATA_DIR", str(repo_dir / "data")))
db_path = data_dir / "devplace.db"
def human(n):
n = float(n)
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}PB"
if not db_path.exists():
print(f" {db_path} does not exist, skipping")
raise SystemExit(0)
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
cur = con.cursor()
def columns(table):
return {row[1] for row in cur.execute(f"PRAGMA table_info('{table}')")}
# attachments: data/uploads/attachments/{directory}/{stored_name}, plus any
# {stem}_thumb.* sibling thumbnail (see attachments._unlink_attachment_files)
cols = columns("attachments")
if {"directory", "stored_name", "deleted_at"} <= cols:
rows = cur.execute(
"SELECT directory, stored_name FROM attachments WHERE deleted_at IS NOT NULL"
).fetchall()
base = data_dir / "uploads" / "attachments"
present, total = 0, 0
for directory, stored_name in rows:
if not directory or not stored_name:
continue
fp = base / directory / stored_name
if fp.exists():
present += 1
try:
total += fp.stat().st_size
except OSError:
pass
stem = Path(stored_name).stem
for thumb in (base / directory).glob(f"{stem}_thumb.*"):
try:
total += thumb.stat().st_size
except OSError:
pass
print(
f" attachments: {len(rows)} soft-deleted row(s), "
f"{present} still on disk, {human(total)} reclaimable"
)
else:
print(f" attachments: unexpected schema, columns={sorted(cols)}")
# project_files: data/uploads/project_files/{directory}/{stored_name},
# only rows with is_binary=1 have a blob at all (text lives in the DB row)
cols = columns("project_files")
if {"directory", "stored_name", "deleted_at", "is_binary"} <= cols:
rows = cur.execute(
"SELECT directory, stored_name FROM project_files "
"WHERE deleted_at IS NOT NULL AND is_binary = 1"
).fetchall()
base = data_dir / "uploads" / "project_files"
present, total = 0, 0
for directory, stored_name in rows:
if not directory or not stored_name:
continue
fp = base / directory / stored_name
if fp.exists():
present += 1
try:
total += fp.stat().st_size
except OSError:
pass
print(
f" project_files: {len(rows)} soft-deleted binary row(s), "
f"{present} still on disk, {human(total)} reclaimable"
)
else:
print(f" project_files: unexpected schema, columns={sorted(cols)}")
finally:
con.close()
PY
echo
if [ "${#FAILED[@]}" -gt 0 ]; then
echo "########################################"
echo "# ${#FAILED[@]} command(s) failed:"
for cmd in "${FAILED[@]}"; do
echo "# $cmd"
done
echo "########################################"
exit 1
fi
echo "All commands completed successfully."
+149
View File
@@ -0,0 +1,149 @@
# retoor <retoor@molodetz.nl>
from devplacepy import attachments as att
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
def _make_attachment(directory, stored_name, deleted_at=None):
uid = generate_uid()
get_table("attachments").insert(
{
"uid": uid,
"deleted_at": deleted_at,
"deleted_by": None,
"directory": directory,
"stored_name": stored_name,
"target_type": "post",
"target_uid": generate_uid(),
}
)
return uid
def _write_blob(base, directory, stored_name, content=b"data"):
file_dir = base / directory
file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(content)
return file_dir / stored_name
def test_purge_soft_deleted_attachments_removes_row_and_blob(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name, b"x" * 100)
uid = _make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments()
assert removed >= 1
assert freed == 100
assert get_table("attachments").find_one(uid=uid) is None
assert not (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name, b"x" * 50)
uid = _make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments(dry_run=True)
assert removed >= 1
assert freed == 50
assert get_table("attachments").find_one(uid=uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_ignores_live_rows(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name)
uid = _make_attachment(directory, stored_name, deleted_at=None)
att.purge_soft_deleted_attachments()
assert get_table("attachments").find_one(uid=uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_attachments_also_removes_thumbnail(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stem = generate_uid()
stored_name = f"{stem}.jpg"
_write_blob(tmp_path, directory, stored_name, b"x" * 20)
_write_blob(tmp_path, directory, f"{stem}_thumb.jpg", b"y" * 5)
_make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.purge_soft_deleted_attachments()
assert removed >= 1
assert freed == 25
assert not (tmp_path / directory / f"{stem}_thumb.jpg").exists()
def test_sweep_orphan_attachment_blobs_removes_unreferenced_file(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
orphan_name = f"{generate_uid()}.o"
_write_blob(tmp_path, directory, orphan_name, b"y" * 30)
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 1
assert freed == 30
assert not (tmp_path / directory / orphan_name).exists()
def test_sweep_orphan_attachment_blobs_keeps_referenced_file_and_its_thumbnail(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stem = generate_uid()
stored_name = f"{stem}.jpg"
thumb_name = f"{stem}_thumb.jpg"
_write_blob(tmp_path, directory, stored_name)
_write_blob(tmp_path, directory, thumb_name)
_make_attachment(directory, stored_name, deleted_at=None)
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
assert (tmp_path / directory / thumb_name).exists()
def test_sweep_orphan_attachment_blobs_keeps_blob_referenced_only_by_soft_deleted_row(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
stored_name = f"{generate_uid()}.png"
_write_blob(tmp_path, directory, stored_name)
_make_attachment(directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00")
removed, freed = att.sweep_orphan_attachment_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
def test_sweep_orphan_attachment_blobs_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path)
directory = "ab/cd"
orphan_name = f"{generate_uid()}.o"
_write_blob(tmp_path, directory, orphan_name, b"z" * 10)
removed, freed = att.sweep_orphan_attachment_blobs(dry_run=True)
assert removed == 1
assert freed == 10
assert (tmp_path / directory / orphan_name).exists()
+41
View File
@@ -161,3 +161,44 @@ def test_main_dispatches_subcommand(local_db, monkeypatch, capsys):
monkeypatch.setattr("sys.argv", ["devplace", "role", "get", username]) monkeypatch.setattr("sys.argv", ["devplace", "role", "get", username])
cli.main() cli.main()
assert capsys.readouterr().out.strip() == "admin" assert capsys.readouterr().out.strip() == "admin"
def test_system_prune_reclaims_orphans_across_subsystems(local_db, tmp_path, capsys, monkeypatch):
from devplacepy import attachments as att
from devplacepy import project_files as pf
monkeypatch.setattr(att, "ATTACHMENTS_DIR", tmp_path / "attachments")
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path / "project_files")
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "workspaces")
attachments_dir = tmp_path / "attachments" / "ab" / "cd"
attachments_dir.mkdir(parents=True)
(attachments_dir / "orphan.png").write_bytes(b"a" * 10)
project_files_dir = tmp_path / "project_files" / "ab" / "cd"
project_files_dir.mkdir(parents=True)
(project_files_dir / "orphan.o").write_bytes(b"b" * 20)
(tmp_path / "workspaces").mkdir()
(tmp_path / "workspaces" / "orphan-project").mkdir()
cli.cmd_system_prune(argparse.Namespace(dry_run=True))
dry_output = capsys.readouterr().out
assert "DRY RUN" in dry_output
assert (attachments_dir / "orphan.png").exists()
assert (project_files_dir / "orphan.o").exists()
assert (tmp_path / "workspaces" / "orphan-project").exists()
cli.cmd_system_prune(argparse.Namespace(dry_run=False))
real_output = capsys.readouterr().out
assert "DRY RUN" not in real_output
assert not (attachments_dir / "orphan.png").exists()
assert not (project_files_dir / "orphan.o").exists()
assert not (tmp_path / "workspaces" / "orphan-project").exists()
def test_system_prune_registered_in_parser():
parser = cli.build_parser()
args = parser.parse_args(["system", "prune", "--dry-run"])
assert args.func is cli.cmd_system_prune
assert args.dry_run is True
+141
View File
@@ -388,3 +388,144 @@ def test_docs_group_present():
assert group is not None assert group is not None
ids = {e["id"] for e in group["endpoints"]} ids = {e["id"] for e in group["endpoints"]}
assert "project-files-write" in ids and "project-files-list" in ids assert "project-files-write" in ids and "project-files-list" in ids
def _insert_binary_node(project_uid, directory, stored_name, deleted_at=None):
from devplacepy.utils import generate_uid
uid = generate_uid()
pf._table().insert(
{
"uid": uid,
"project_uid": project_uid,
"deleted_at": deleted_at,
"deleted_by": None,
"user_uid": "system-prune-test-user",
"path": f"/{stored_name}",
"name": stored_name,
"parent_path": "/",
"type": "file",
"content": None,
"is_binary": 1,
"stored_name": stored_name,
"directory": directory,
"mime_type": "application/octet-stream",
"size": 0,
"created_at": pf._now(),
"updated_at": pf._now(),
}
)
return uid
def _write_project_blob(base, directory, stored_name, content=b"data"):
file_dir = base / directory
file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(content)
def test_purge_soft_deleted_project_files_removes_row_and_blob(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
stored_name = "obj.o"
_write_project_blob(tmp_path, directory, stored_name, b"x" * 40)
uid = _insert_binary_node(
"sys-prune-proj-1", directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00"
)
removed, freed = pf.purge_soft_deleted_project_files()
assert removed >= 1
assert freed == 40
assert pf._table().find_one(uid=uid) is None
assert not (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_project_files_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
stored_name = "obj2.o"
_write_project_blob(tmp_path, directory, stored_name, b"x" * 15)
uid = _insert_binary_node(
"sys-prune-proj-2", directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00"
)
removed, freed = pf.purge_soft_deleted_project_files(dry_run=True)
assert removed >= 1
assert freed == 15
assert pf._table().find_one(uid=uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_purge_soft_deleted_project_files_ignores_live_and_text_rows(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
stored_name = "live.o"
_write_project_blob(tmp_path, directory, stored_name)
live_uid = _insert_binary_node("sys-prune-proj-3", directory, stored_name, deleted_at=None)
pf.purge_soft_deleted_project_files()
assert pf._table().find_one(uid=live_uid) is not None
assert (tmp_path / directory / stored_name).exists()
def test_sweep_orphan_project_file_blobs_removes_unreferenced_file(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
orphan_name = "leftover.right"
_write_project_blob(tmp_path, directory, orphan_name, b"q" * 60)
removed, freed = pf.sweep_orphan_project_file_blobs()
assert removed == 1
assert freed == 60
assert not (tmp_path / directory / orphan_name).exists()
def test_sweep_orphan_project_file_blobs_keeps_referenced_file(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
stored_name = "kept.bin"
_write_project_blob(tmp_path, directory, stored_name)
_insert_binary_node("sys-prune-proj-4", directory, stored_name, deleted_at=None)
removed, freed = pf.sweep_orphan_project_file_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
def test_sweep_orphan_project_file_blobs_keeps_blob_referenced_only_by_soft_deleted_row(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
stored_name = "still-soft-deleted.bin"
_write_project_blob(tmp_path, directory, stored_name)
_insert_binary_node(
"sys-prune-proj-5", directory, stored_name, deleted_at="2020-01-01T00:00:00+00:00"
)
removed, freed = pf.sweep_orphan_project_file_blobs()
assert removed == 0
assert freed == 0
assert (tmp_path / directory / stored_name).exists()
def test_sweep_orphan_project_file_blobs_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(pf, "PROJECT_FILES_DIR", tmp_path)
directory = "ab/cd"
orphan_name = "dry.right"
_write_project_blob(tmp_path, directory, orphan_name, b"w" * 12)
removed, freed = pf.sweep_orphan_project_file_blobs(dry_run=True)
assert removed == 1
assert freed == 12
assert (tmp_path / directory / orphan_name).exists()
+79
View File
@@ -570,6 +570,85 @@ def test_bidirectional_sync_manifest_is_pruned_after_both_sides_agree(env, tmp_p
assert remaining == [] assert remaining == []
def test_bidirectional_sync_skips_build_artifact_directories(env, tmp_path):
workspace = tmp_path / "sync-build-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
(workspace / "build").mkdir()
(workspace / "build" / "output.bin").write_text("junk")
(workspace / "dist").mkdir()
(workspace / "dist" / "bundle.js").write_text("junk")
(workspace / "real.txt").write_text("keep me\n")
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["imported"] == 1
assert project_files.get_node(pid, "real.txt") is not None
assert project_files.get_node(pid, "build/output.bin") is None
assert project_files.get_node(pid, "dist/bundle.js") is None
def test_bidirectional_sync_skips_compiled_artifact_extensions(env, tmp_path):
workspace = tmp_path / "sync-ext-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
(workspace / "main.o").write_bytes(b"junk")
(workspace / "helper.pyc").write_bytes(b"junk")
(workspace / "main.c").write_text("int main() { return 0; }\n")
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
assert counts["imported"] == 1
assert project_files.get_node(pid, "main.c") is not None
assert project_files.get_node(pid, "main.o") is None
assert project_files.get_node(pid, "helper.pyc") is None
def test_sync_dir_bidirectional_locked_skips_concurrent_run_for_same_project(
env, tmp_path
):
workspace = tmp_path / "sync-lock-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
lock = api._sync_lock_for(pid)
lock.acquire()
try:
counts = api._sync_dir_bidirectional_locked(pid, str(workspace), user)
finally:
lock.release()
assert counts == {
"exported": 0,
"imported": 0,
"deleted_in_project": 0,
"deleted_in_workspace": 0,
}
assert project_files.get_node(pid, "real.txt") is None
def test_sync_dir_bidirectional_locked_runs_when_lock_is_free(env, tmp_path):
workspace = tmp_path / "sync-lock-free-ws"
workspace.mkdir()
pid = env["project"]["uid"]
user = env["user"]
(workspace / "real.txt").write_text("keep me\n")
counts = api._sync_dir_bidirectional_locked(pid, str(workspace), user)
assert counts["imported"] == 1
assert project_files.get_node(pid, "real.txt") is not None
def test_sync_dir_bidirectional_locked_is_per_project(env, tmp_path):
other_pid = "sync-lock-other-project"
lock = api._sync_lock_for(other_pid)
assert lock is not api._sync_lock_for(env["project"]["uid"])
def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict: def _proxy_scope(kind: str, headers: dict, scheme: str, query: str = "") -> dict:
return { return {
+76
View File
@@ -0,0 +1,76 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.database import get_table, init_db
from devplacepy.services.containers import store
from devplacepy.utils import generate_uid
@pytest.fixture(autouse=True)
def _init_db_containers_store():
init_db()
yield
def _make_instance(project_uid, deleted_at=None):
uid = generate_uid()
get_table("instances").insert(
{
"uid": uid,
"project_uid": project_uid,
"deleted_at": deleted_at,
"deleted_by": None,
"name": f"inst-{uid[:8]}",
"status": "running",
"desired_state": "running",
}
)
return uid
def test_gc_workspaces_removes_directories_with_no_live_instance(
local_db, tmp_path, monkeypatch
):
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
active_project = "gcws-active-project"
orphan_project = "gcws-orphan-project"
(tmp_path / active_project).mkdir()
(tmp_path / orphan_project).mkdir()
_make_instance(active_project)
removed = store.gc_workspaces()
assert removed == 1
assert (tmp_path / active_project).exists()
assert not (tmp_path / orphan_project).exists()
def test_gc_workspaces_ignores_soft_deleted_instances(local_db, tmp_path, monkeypatch):
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
project = "gcws-soft-deleted-project"
(tmp_path / project).mkdir()
_make_instance(project, deleted_at="2020-01-01T00:00:00+00:00")
removed = store.gc_workspaces()
assert removed == 1
assert not (tmp_path / project).exists()
def test_gc_workspaces_dry_run_changes_nothing(local_db, tmp_path, monkeypatch):
monkeypatch.setattr("devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path)
orphan_project = "gcws-dry-run-project"
(tmp_path / orphan_project).mkdir()
removed = store.gc_workspaces(dry_run=True)
assert removed == 1
assert (tmp_path / orphan_project).exists()
def test_gc_workspaces_missing_base_dir_is_a_noop(local_db, tmp_path, monkeypatch):
monkeypatch.setattr(
"devplacepy.config.CONTAINER_WORKSPACES_DIR", tmp_path / "does-not-exist"
)
assert store.gc_workspaces() == 0