Make workspace/project file sync propagate deletions instead of resurrecting them
The old sync compared only the two live sides (project rows vs workspace files), so a file present in the project but missing on disk was indistinguishable from "never materialized here yet" - it always got re-exported, which is why deleting a file inside a container made it come back. The mirror direction had the same bug: a file deleted from the project's file editor was silently re-imported from the container's stale copy on the next tick. Fixes it with a persisted per-file sync baseline (new project_file_sync_state table: db_epoch/fs_epoch as they stood right after the previous sync), the same role a rsync/Unison state file plays in any real bidirectional sync. Deleting on either side now propagates to the other, unless the deleted side's counterpart was edited after the last sync, in which case the edit wins and the file is restored. A read-only project always exports (never imports, including on tie) and always removes a workspace's stale local copy, so it stays a faithful mirror. Sync of an unchanged file is now a true no-op (zero writes) instead of rewriting it every ~60s tick forever. sync_dir_bidirectional's return dict gains deleted_in_project/ deleted_in_workspace alongside exported/imported; both API call sites already pass the whole dict through untouched. The instance sync toast now summarizes all four counts instead of just imports. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
This commit is contained in:
@@ -462,7 +462,7 @@ and its full configuration are documented automatically - including future servi
|
|||||||
|
|
||||||
### Container manager (admin only)
|
### Container manager (admin only)
|
||||||
|
|
||||||
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional, newer-wins sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention (the sync only ever creates or overwrites the older copy of a file, never deletes one; a read-only project is export-only). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
|
`services/containers/` runs supervised container instances from the web UI, the HTTP API, and Devii. It drives the `docker` CLI via async subprocesses behind a pluggable `Backend` interface (a `DockerCliBackend` plus a `FakeBackend` for tests). **There is no in-app image building.** Every instance runs ONE shared prebuilt image, `ppy:latest` (override `DEVPLACE_CONTAINER_IMAGE`), built once with **`make ppy`** from `ppy.Dockerfile`: a Python + Playwright base with a broad set of common libraries preinstalled, the `pravda` (uid 1000) user, the sudo superclone, `pagent` at `/usr/bin/pagent.py`, and the `code-server` browser IDE that backs dev workspaces all baked in. Creating an instance is then an instant `docker run` (it fails fast with a clear error if the `ppy` image has not been built yet). `ContainerService` reconciles desired instance state against `docker ps` each tick (containers are labeled `devplace.instance=<uid>`, so orphans are reaped and no state is lost), applies restart policies, fires cron/interval/one-time schedules, and samples metrics. The container's `/app` is bind-mounted to a persistent project workspace (materialized from the project files) and **stays in sync automatically**: the manager runs a bidirectional sync between the project files and the workspace on every start and roughly once a minute while running, so edits made inside the container and edits made in the project file editor converge without manual intervention. Deletions propagate too, in both directions: deleting a file inside the container removes it from the project, and deleting it in the project's file editor removes it from the container, tracked against a per-file sync baseline so a genuine deletion is never confused with a file that simply has not been materialized to that workspace yet; an edit made after a conflicting deletion always wins and restores the file (a read-only project is export-only and always mirrors the project verbatim, including removing files the project no longer has). Projects that need extra packages use runtime `pip install` (pravda owns the site-packages, no sudo needed) or `apt install` directly (the `pravda` user runs `apt`/`dpkg` through a fakeroot wrapper, so system packages install without root) or add the library to `ppy.Dockerfile` and rerun `make ppy`. Each instance can run a **boot script** in Python or Bash (written into the workspace and run on launch) or a plain boot command, can be set to **start automatically** whenever the container service starts, and can be configured to **run as** a chosen DevPlace user - which only selects whose identity and API key are injected into the container (the container always runs as the unprivileged `pravda` user). Every status change is recorded and shown as a status history on the instance page. A running instance can be **published** with an `ingress_slug`, making its service reachable (HTTP and WebSocket) at `/p/<slug>` through DevPlace. The manager is reached two ways: the admin **Containers** sidebar entry (`/admin/containers`) lists, creates, edits, and controls every instance across all projects and opens a dedicated detail page per instance, and each project page carries an admin-only **Containers** button to its own instance manager.
|
||||||
|
|
||||||
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
|
**Security:** this requires mounting the Docker socket, which grants host root. Every run, exec, lifecycle, and schedule operation is administrator-only; `--privileged` is never used and all docker calls are argument-list subprocesses. Containers are additionally isolated per user: an instance is managed only by its owner (the administrator who created it, or the owner of its project) and by the primary administrator, who alone sees and manages every instance including those on private projects; other administrators get a read-only view of instances on public projects and none of another user's private-project instances (exec, terminals, schedules, edits, and lifecycle actions are all refused and audited as denied). The service is disabled by default; an admin enables **Containers** on `/admin/services`. CLI: `devplace containers list | reconcile | prune | prune-builds | gc-workspaces` (`prune-builds` is a one-time cleanup that removes legacy per-project images and the old dockerfiles/builds tables).
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,22 @@ def init_db():
|
|||||||
_index(
|
_index(
|
||||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||||
)
|
)
|
||||||
|
project_file_sync_state = get_table("project_file_sync_state")
|
||||||
|
for column, example in (
|
||||||
|
("project_uid", ""),
|
||||||
|
("path", ""),
|
||||||
|
("db_epoch", 0.0),
|
||||||
|
("fs_epoch", 0.0),
|
||||||
|
):
|
||||||
|
if not project_file_sync_state.has_column(column):
|
||||||
|
project_file_sync_state.create_column_by_example(column, example)
|
||||||
|
_index(
|
||||||
|
db,
|
||||||
|
"project_file_sync_state",
|
||||||
|
"idx_project_file_sync_state_path",
|
||||||
|
["project_uid", "path"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||||
_drop_index(db, "idx_follows_follower")
|
_drop_index(db, "idx_follows_follower")
|
||||||
|
|||||||
+151
-32
@@ -477,6 +477,7 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
|
|||||||
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
||||||
if "project_files" not in db.tables:
|
if "project_files" not in db.tables:
|
||||||
return
|
return
|
||||||
|
clear_sync_state(project_uid)
|
||||||
stamp = _now()
|
stamp = _now()
|
||||||
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
||||||
_table().update(
|
_table().update(
|
||||||
@@ -486,6 +487,7 @@ def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
_SYNC_CLOCK_SKEW_SECONDS = 1.0
|
_SYNC_CLOCK_SKEW_SECONDS = 1.0
|
||||||
|
SYNC_STATE_TABLE = "project_file_sync_state"
|
||||||
|
|
||||||
|
|
||||||
def _epoch_of(iso_timestamp) -> float:
|
def _epoch_of(iso_timestamp) -> float:
|
||||||
@@ -497,6 +499,48 @@ def _epoch_of(iso_timestamp) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_state_table():
|
||||||
|
return get_table(SYNC_STATE_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sync_manifest(project_uid: str) -> dict:
|
||||||
|
if SYNC_STATE_TABLE not in db.tables:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
row["path"]: {"db_epoch": row["db_epoch"], "fs_epoch": row["fs_epoch"]}
|
||||||
|
for row in _sync_state_table().find(project_uid=project_uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_sync_manifest(project_uid: str, old: dict, new: dict) -> None:
|
||||||
|
table = _sync_state_table()
|
||||||
|
for path in old:
|
||||||
|
if path not in new:
|
||||||
|
table.delete(project_uid=project_uid, path=path)
|
||||||
|
for path, entry in new.items():
|
||||||
|
if old.get(path) == entry:
|
||||||
|
continue
|
||||||
|
table.upsert(
|
||||||
|
{"project_uid": project_uid, "path": path, **entry},
|
||||||
|
["project_uid", "path"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_sync_state(project_uid: str) -> None:
|
||||||
|
if SYNC_STATE_TABLE not in db.tables:
|
||||||
|
return
|
||||||
|
_sync_state_table().delete(project_uid=project_uid)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_db_row_for_sync(row: dict, deleted_by: str) -> None:
|
||||||
|
_table().update(
|
||||||
|
{"uid": row["uid"], "deleted_at": _now(), "deleted_by": deleted_by},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
if row.get("is_binary"):
|
||||||
|
_unlink_blob(row)
|
||||||
|
|
||||||
|
|
||||||
def _file_records(project_uid: str) -> dict:
|
def _file_records(project_uid: str) -> dict:
|
||||||
records: dict = {}
|
records: dict = {}
|
||||||
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
for row in _table().find(project_uid=project_uid, deleted_at=None):
|
||||||
@@ -536,48 +580,121 @@ def _workspace_records(workspace) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def sync_dir_bidirectional(project_uid: str, workspace, user: dict) -> dict:
|
def sync_dir_bidirectional(project_uid: str, workspace, user: dict) -> dict:
|
||||||
|
empty = {
|
||||||
|
"exported": 0,
|
||||||
|
"imported": 0,
|
||||||
|
"deleted_in_project": 0,
|
||||||
|
"deleted_in_workspace": 0,
|
||||||
|
}
|
||||||
if "project_files" not in db.tables:
|
if "project_files" not in db.tables:
|
||||||
return {"exported": 0, "imported": 0}
|
return empty
|
||||||
readonly = is_readonly(project_uid)
|
readonly = is_readonly(project_uid)
|
||||||
dest = Path(workspace).resolve()
|
dest = Path(workspace).resolve()
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
db_files = _file_records(project_uid)
|
db_files = _file_records(project_uid)
|
||||||
fs_files = _workspace_records(dest)
|
fs_files = _workspace_records(dest)
|
||||||
exported = 0
|
manifest = _load_sync_manifest(project_uid)
|
||||||
imported = 0
|
new_manifest: dict = {}
|
||||||
|
counts = dict(empty)
|
||||||
|
|
||||||
for path, row in db_files.items():
|
for path in set(manifest) | set(db_files) | set(fs_files):
|
||||||
|
row = db_files.get(path)
|
||||||
fs_full = fs_files.get(path)
|
fs_full = fs_files.get(path)
|
||||||
if fs_full is None:
|
entry = manifest.get(path)
|
||||||
_export_node(row, dest)
|
|
||||||
exported += 1
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
fs_mtime = fs_full.stat().st_mtime
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
db_mtime = _epoch_of(row.get("updated_at"))
|
|
||||||
if db_mtime >= fs_mtime - _SYNC_CLOCK_SKEW_SECONDS:
|
|
||||||
_export_node(row, dest)
|
|
||||||
exported += 1
|
|
||||||
elif not readonly:
|
|
||||||
if _import_file(project_uid, user, path, fs_full):
|
|
||||||
imported += 1
|
|
||||||
|
|
||||||
if not readonly:
|
if row is not None and fs_full is not None:
|
||||||
for path, fs_full in fs_files.items():
|
try:
|
||||||
if path in db_files:
|
fs_epoch = fs_full.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
continue
|
continue
|
||||||
if _import_file(project_uid, user, path, fs_full):
|
db_epoch = _epoch_of(row.get("updated_at"))
|
||||||
imported += 1
|
if (
|
||||||
|
entry is not None
|
||||||
|
and abs(db_epoch - entry["db_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
|
||||||
|
and abs(fs_epoch - entry["fs_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
|
||||||
|
):
|
||||||
|
new_manifest[path] = entry
|
||||||
|
continue
|
||||||
|
if db_epoch >= fs_epoch - _SYNC_CLOCK_SKEW_SECONDS or readonly:
|
||||||
|
recorded = _record_export(row, dest)
|
||||||
|
if recorded:
|
||||||
|
counts["exported"] += 1
|
||||||
|
new_manifest[path] = recorded
|
||||||
|
else:
|
||||||
|
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||||
|
if recorded:
|
||||||
|
counts["imported"] += 1
|
||||||
|
new_manifest[path] = recorded
|
||||||
|
continue
|
||||||
|
|
||||||
return {"exported": exported, "imported": imported}
|
if row is not None and fs_full is None:
|
||||||
|
db_epoch = _epoch_of(row.get("updated_at"))
|
||||||
|
if (
|
||||||
|
entry is None
|
||||||
|
or readonly
|
||||||
|
or db_epoch > entry["db_epoch"] + _SYNC_CLOCK_SKEW_SECONDS
|
||||||
|
):
|
||||||
|
recorded = _record_export(row, dest)
|
||||||
|
if recorded:
|
||||||
|
counts["exported"] += 1
|
||||||
|
new_manifest[path] = recorded
|
||||||
|
else:
|
||||||
|
_delete_db_row_for_sync(row, user["uid"])
|
||||||
|
counts["deleted_in_project"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if row is None and fs_full is not None:
|
||||||
|
try:
|
||||||
|
fs_epoch = fs_full.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if entry is None:
|
||||||
|
if not readonly:
|
||||||
|
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||||
|
if recorded:
|
||||||
|
counts["imported"] += 1
|
||||||
|
new_manifest[path] = recorded
|
||||||
|
continue
|
||||||
|
if not readonly and fs_epoch > entry["fs_epoch"] + _SYNC_CLOCK_SKEW_SECONDS:
|
||||||
|
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
|
||||||
|
if recorded:
|
||||||
|
counts["imported"] += 1
|
||||||
|
new_manifest[path] = recorded
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fs_full.unlink()
|
||||||
|
counts["deleted_in_workspace"] += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
_save_sync_manifest(project_uid, manifest, new_manifest)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _export_node(row: dict, dest: Path) -> None:
|
def _record_export(row: dict, dest: Path):
|
||||||
|
target = _export_node(row, dest)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
db_epoch = _epoch_of(row.get("updated_at"))
|
||||||
|
try:
|
||||||
|
fs_epoch = target.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
fs_epoch = db_epoch
|
||||||
|
return {"db_epoch": db_epoch, "fs_epoch": fs_epoch}
|
||||||
|
|
||||||
|
|
||||||
|
def _record_import(project_uid: str, user: dict, path: str, fs_full: Path, fs_epoch: float):
|
||||||
|
imported = _import_file(project_uid, user, path, fs_full)
|
||||||
|
if imported is None:
|
||||||
|
return None
|
||||||
|
return {"db_epoch": _epoch_of(imported.get("updated_at")), "fs_epoch": fs_epoch}
|
||||||
|
|
||||||
|
|
||||||
|
def _export_node(row: dict, dest: Path):
|
||||||
target = (dest / row["path"]).resolve()
|
target = (dest / row["path"]).resolve()
|
||||||
if target != dest and not target.is_relative_to(dest):
|
if target != dest and not target.is_relative_to(dest):
|
||||||
return
|
return None
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if target.is_symlink():
|
if target.is_symlink():
|
||||||
target.unlink()
|
target.unlink()
|
||||||
@@ -587,20 +704,21 @@ def _export_node(row: dict, dest: Path) -> None:
|
|||||||
shutil.copyfile(src, target)
|
shutil.copyfile(src, target)
|
||||||
except (FileNotFoundError, OSError):
|
except (FileNotFoundError, OSError):
|
||||||
logger.warning("Blob file missing during export: %s", src)
|
logger.warning("Blob file missing during export: %s", src)
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
target.write_text(row.get("content") or "", encoding="utf-8")
|
target.write_text(row.get("content") or "", encoding="utf-8")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path) -> bool:
|
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path):
|
||||||
try:
|
try:
|
||||||
data = fs_full.read_bytes()
|
data = fs_full.read_bytes()
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return None
|
||||||
try:
|
try:
|
||||||
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
|
return store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
|
||||||
return True
|
|
||||||
except ProjectFileError:
|
except ProjectFileError:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
|
||||||
def node_to_dict(row: dict) -> dict:
|
def node_to_dict(row: dict) -> dict:
|
||||||
@@ -875,6 +993,7 @@ def append_lines(project_uid: str, raw_path: str, content: str) -> dict:
|
|||||||
def delete_all_project_files(project_uid: str) -> None:
|
def delete_all_project_files(project_uid: str) -> None:
|
||||||
if "project_files" not in db.tables:
|
if "project_files" not in db.tables:
|
||||||
return
|
return
|
||||||
|
clear_sync_state(project_uid)
|
||||||
for row in _table().find(project_uid=project_uid):
|
for row in _table().find(project_uid=project_uid):
|
||||||
if row.get("is_binary"):
|
if row.get("is_binary"):
|
||||||
_unlink_blob(row)
|
_unlink_blob(row)
|
||||||
|
|||||||
@@ -292,11 +292,62 @@ The container manager drives the host docker daemon, which needs heavy wiring -
|
|||||||
|
|
||||||
**The DooD bind-mount gotcha:** `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must be mounted at an identical host+container path (the make targets use `$(CURDIR)/data` on both sides; manual `docker compose` users get a `/srv/devplace-data` default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".
|
**The DooD bind-mount gotcha:** `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must be mounted at an identical host+container path (the make targets use `$(CURDIR)/data` on both sides; manual `docker compose` users get a `/srv/devplace-data` default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".
|
||||||
|
|
||||||
## Bidirectional newer-wins sync (load-bearing direction rule)
|
## Bidirectional sync with deletion propagation (load-bearing direction rule)
|
||||||
|
|
||||||
Sync is NOT one-directional import. `project_files.sync_dir_bidirectional(project_uid, workspace, user) -> {"exported", "imported"}` is the one helper that reconciles a project's virtual FS against an instance's `workspace_dir`: per file, the side with the newer timestamp wins (project `updated_at` epoch, via `datetime.fromisoformat(...).timestamp()`, vs filesystem `st_mtime`, with a 1s skew tolerance favouring export on ties), and a file present on only one side propagates to the other. It **NEVER deletes a file** - only creates/overwrites the older side. A **read-only** project (`is_readonly`) exports only, never imports (the read-only guard direction).
|
Sync is NOT one-directional import, and it is not merely "newer wins" either - it propagates
|
||||||
|
deletions on BOTH sides, which requires more state than comparing two live snapshots can
|
||||||
|
ever provide. `project_files.sync_dir_bidirectional(project_uid, workspace, user) ->
|
||||||
|
{"exported", "imported", "deleted_in_project", "deleted_in_workspace"}` reconciles a
|
||||||
|
project's virtual FS against an instance's `workspace_dir` against a **third,
|
||||||
|
persisted state: the manifest of what was true after the previous sync**
|
||||||
|
(`project_file_sync_state`, one row per `(project_uid, path)` holding `db_epoch` +
|
||||||
|
`fs_epoch` as they stood right after that prior sync wrote them - never a guess, always
|
||||||
|
re-read from the actual post-write `stat()`/row so a later comparison is exact). Comparing
|
||||||
|
only the two live sides can never tell "never synced here yet, please export it" apart from
|
||||||
|
"was here, got deleted, please propagate that" - both look identical (present in the
|
||||||
|
project, absent on disk) with no baseline. The manifest is exactly that baseline, the same
|
||||||
|
role a `.git` index or an rsync/Unison state file plays in any real bidirectional sync.
|
||||||
|
|
||||||
Both `api.sync_workspace` (HTTP/Devii sync action, returns `{exported, imported}`) and `api.sync_bidirectional_sync` (reconciler, non-blocking `record_event` system actor, logs only when non-zero) call the same helper. The reconciler runs it before every `_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`, gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they never round-trip into the project.
|
Per path, three states are possible (`db_files`/`fs_files`/`manifest`, keyed by path):
|
||||||
|
|
||||||
|
- **Present both sides.** Newer wins as before (`db_epoch >= fs_epoch - 1s` skew exports,
|
||||||
|
otherwise imports) - a read-only project always exports here too (see below).
|
||||||
|
- **Present in the project, absent on disk.** Not in the manifest (or read-only) -> never
|
||||||
|
materialized here, export it. In the manifest -> the workspace deleted it since the last
|
||||||
|
sync -> **propagate: soft-delete the project row** (`_delete_db_row_for_sync`, stamps
|
||||||
|
`deleted_at`/`deleted_by=user["uid"]`, unlinks the blob) - UNLESS the project's own copy
|
||||||
|
was edited after that last sync (`db_epoch` newer than the manifest's), in which case the
|
||||||
|
edit wins and the file is restored instead of deleted.
|
||||||
|
- **Present on disk, absent from the project.** Not in the manifest -> a brand new file
|
||||||
|
created in the workspace -> import it. In the manifest -> the project deleted it since
|
||||||
|
the last sync -> **propagate: delete the local file** - UNLESS it was edited locally after
|
||||||
|
that last sync, in which case the edit wins and it is re-imported (resurrected). A failed
|
||||||
|
edit-wins import is left untouched rather than deleted, so a transient I/O error can never
|
||||||
|
destroy the only remaining copy.
|
||||||
|
|
||||||
|
**A read-only project always wins, in both directions**: present-both-sides always exports
|
||||||
|
(a locally-newer edit in a read-only workspace is discarded, never imported - it could never
|
||||||
|
round-trip anyway); a local deletion is always restored (never propagated - read-only means
|
||||||
|
the workspace mirrors the project verbatim, it does not get to delete from it); and when the
|
||||||
|
*project* deletes a file, its now-stale local copy in a read-only workspace is still removed
|
||||||
|
(that is export-direction cleanup, not an import, so it does not violate "read-only never
|
||||||
|
imports").
|
||||||
|
|
||||||
|
The manifest is written with a diff, not a full rewrite: `_save_sync_manifest` deletes only
|
||||||
|
the rows for paths that left the merged state and upserts only the rows whose entry actually
|
||||||
|
changed, so an idle project with nothing to sync costs zero writes on the next tick despite
|
||||||
|
having thousands of files. It is cleared entirely (`clear_sync_state`) whenever a project's
|
||||||
|
files are wiped (`soft_delete_all_project_files`, `delete_all_project_files` / fork
|
||||||
|
rollback) - a later restore starts the baseline fresh, which just means the first sync after
|
||||||
|
restore treats everything as newly seen (safe, not destructive).
|
||||||
|
|
||||||
|
Both `api.sync_workspace` (HTTP/Devii sync action) and `api.sync_bidirectional_sync`
|
||||||
|
(reconciler, non-blocking `record_event` system actor, logs only when any count is nonzero)
|
||||||
|
call the same helper and return/record all four counts. The reconciler runs it before every
|
||||||
|
`_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`,
|
||||||
|
gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance
|
||||||
|
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.
|
||||||
|
|
||||||
## Run-as user = identity + API key ONLY (load-bearing constraint)
|
## Run-as user = identity + API key ONLY (load-bearing constraint)
|
||||||
|
|
||||||
|
|||||||
@@ -100,11 +100,21 @@ export class ContainerInstance {
|
|||||||
: `${this.base}/instances/${this.uid}/${action}`;
|
: `${this.base}/instances/${this.uid}/${action}`;
|
||||||
const res = await Http.send(url, {});
|
const res = await Http.send(url, {});
|
||||||
if (action === "delete") { window.location.href = "/admin/containers"; return; }
|
if (action === "delete") { window.location.href = "/admin/containers"; return; }
|
||||||
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
|
if (action === "sync") this.toast(this.syncSummary(res.data), "success");
|
||||||
else this.toast(`${action} requested`, "success");
|
else this.toast(`${action} requested`, "success");
|
||||||
} catch (err) { this.toast(err.message, "error"); }
|
} catch (err) { this.toast(err.message, "error"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSummary(data) {
|
||||||
|
const counts = data || {};
|
||||||
|
const parts = [];
|
||||||
|
if (counts.exported) parts.push(`${counts.exported} exported`);
|
||||||
|
if (counts.imported) parts.push(`${counts.imported} imported`);
|
||||||
|
if (counts.deleted_in_project) parts.push(`${counts.deleted_in_project} deleted in project`);
|
||||||
|
if (counts.deleted_in_workspace) parts.push(`${counts.deleted_in_workspace} deleted in workspace`);
|
||||||
|
return parts.length ? `Synced: ${parts.join(", ")}` : "Synced: already up to date";
|
||||||
|
}
|
||||||
|
|
||||||
startDetailPoll() {
|
startDetailPoll() {
|
||||||
this.detailPoll = new Poller(async () => {
|
this.detailPoll = new Poller(async () => {
|
||||||
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
|
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from tests.conftest import BASE_URL, run_async
|
from tests.conftest import BASE_URL, run_async
|
||||||
@@ -64,6 +65,14 @@ def env(tmp_path, monkeypatch):
|
|||||||
for row in list(get_table("project_files").find()):
|
for row in list(get_table("project_files").find()):
|
||||||
if str(row.get("project_uid", "")).startswith("ctest"):
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||||
get_table("project_files").delete(uid=row["uid"])
|
get_table("project_files").delete(uid=row["uid"])
|
||||||
|
if "project_file_sync_state" in db.tables:
|
||||||
|
for row in list(get_table("project_file_sync_state").find()):
|
||||||
|
if str(row.get("project_uid", "")).startswith("ctest"):
|
||||||
|
get_table("project_file_sync_state").delete(
|
||||||
|
project_uid=row["project_uid"], path=row["path"]
|
||||||
|
)
|
||||||
|
if "projects" in db.tables:
|
||||||
|
get_table("projects").delete(uid=pid)
|
||||||
def _ready_instance(env, **kwargs):
|
def _ready_instance(env, **kwargs):
|
||||||
return run_async(
|
return run_async(
|
||||||
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
api.create_instance(env["project"], name=kwargs.pop("name", "inst"), **kwargs)
|
||||||
@@ -405,6 +414,162 @@ def test_bidirectional_sync_newer_wins(env, tmp_path):
|
|||||||
assert imported["content"] == "from fs\n"
|
assert imported["content"] == "from fs\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_propagates_a_workspace_deletion(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-del-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "gone.txt", "bye\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
assert (workspace / "gone.txt").exists()
|
||||||
|
|
||||||
|
(workspace / "gone.txt").unlink()
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_project"] == 1
|
||||||
|
assert counts["exported"] == 0
|
||||||
|
assert project_files.get_node(pid, "gone.txt") is None
|
||||||
|
assert not (workspace / "gone.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_propagates_a_project_deletion(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-pdel-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "removeme.txt", "bye\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
assert (workspace / "removeme.txt").exists()
|
||||||
|
|
||||||
|
project_files.delete_node(pid, "removeme.txt", deleted_by=user["uid"])
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_workspace"] == 1
|
||||||
|
assert counts["imported"] == 0
|
||||||
|
assert not (workspace / "removeme.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_a_later_edit_restores_a_workspace_deletion(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-edit-restore-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
node = project_files.write_text_file(pid, user, "edited.txt", "v1\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
(workspace / "edited.txt").unlink()
|
||||||
|
future = datetime.now(timezone.utc) + timedelta(seconds=10)
|
||||||
|
get_table("project_files").update(
|
||||||
|
{"uid": node["uid"], "content": "v2\n", "updated_at": future.isoformat()},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_project"] == 0
|
||||||
|
assert counts["exported"] == 1
|
||||||
|
assert (workspace / "edited.txt").read_text() == "v2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_a_later_local_edit_reimports_over_a_project_deletion(
|
||||||
|
env, tmp_path
|
||||||
|
):
|
||||||
|
workspace = tmp_path / "sync-edit-reimport-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "revived.txt", "v1\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
project_files.delete_node(pid, "revived.txt", deleted_by=user["uid"])
|
||||||
|
target = workspace / "revived.txt"
|
||||||
|
target.write_text("v2\n")
|
||||||
|
future = (datetime.now(timezone.utc) + timedelta(seconds=10)).timestamp()
|
||||||
|
os.utime(target, (future, future))
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_workspace"] == 0
|
||||||
|
assert counts["imported"] == 1
|
||||||
|
revived = project_files.read_file(pid, "revived.txt")
|
||||||
|
assert revived["content"] == "v2\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_readonly_always_restores_a_workspace_deletion(
|
||||||
|
env, tmp_path
|
||||||
|
):
|
||||||
|
workspace = tmp_path / "sync-ro-restore-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "frozen.txt", "kept\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
get_table("projects").upsert(
|
||||||
|
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
(workspace / "frozen.txt").unlink()
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_project"] == 0
|
||||||
|
assert counts["exported"] == 1
|
||||||
|
assert (workspace / "frozen.txt").read_text() == "kept\n"
|
||||||
|
assert project_files.get_node(pid, "frozen.txt") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_readonly_removes_a_stale_local_copy(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-ro-remove-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "stale.txt", "stale\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
project_files.delete_node(pid, "stale.txt", deleted_by=user["uid"])
|
||||||
|
get_table("projects").upsert(
|
||||||
|
{"uid": pid, "slug": "ctest", "read_only": 1}, ["uid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts["deleted_in_workspace"] == 1
|
||||||
|
assert counts["imported"] == 0
|
||||||
|
assert not (workspace / "stale.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_is_a_noop_once_both_sides_settle(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-noop-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "settled.txt", "steady\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
before = (workspace / "settled.txt").stat().st_mtime
|
||||||
|
|
||||||
|
counts = project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
assert counts == {
|
||||||
|
"exported": 0,
|
||||||
|
"imported": 0,
|
||||||
|
"deleted_in_project": 0,
|
||||||
|
"deleted_in_workspace": 0,
|
||||||
|
}
|
||||||
|
assert (workspace / "settled.txt").stat().st_mtime == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_bidirectional_sync_manifest_is_pruned_after_both_sides_agree(env, tmp_path):
|
||||||
|
workspace = tmp_path / "sync-prune-ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
pid = env["project"]["uid"]
|
||||||
|
user = env["user"]
|
||||||
|
project_files.write_text_file(pid, user, "prune.txt", "x\n")
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
(workspace / "prune.txt").unlink()
|
||||||
|
project_files.sync_dir_bidirectional(pid, str(workspace), user)
|
||||||
|
|
||||||
|
remaining = list(
|
||||||
|
get_table("project_file_sync_state").find(project_uid=pid, path="prune.txt")
|
||||||
|
)
|
||||||
|
assert remaining == []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user