Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions stacklets/memory/hooks/on_start_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def remote_for(tok: str) -> str:
if ensure_vault_cloned(vault, remote):
ctx.step(f"Memory vault cloned to {vault}")
else:
ctx.step(f"Memory vault clone failed at {vault}")
ctx.warn(f"Memory vault clone failed at {vault}")
return
else:
if remote:
Expand Down Expand Up @@ -140,4 +140,4 @@ def remote_for(tok: str) -> str:
if result.get("cloned_brain"):
ctx.step(f"Memory: cloned brain projection to {brain}")
elif not (brain / ".git").exists():
ctx.step(f"Memory brain projection clone failed at {brain}")
ctx.warn(f"Memory brain projection clone failed at {brain}: the wiki and the diary will not update")
44 changes: 42 additions & 2 deletions stacklets/memory/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import json
import math
import re
import shutil
import subprocess
import time
import unicodedata
Expand Down Expand Up @@ -277,6 +278,45 @@ def ensure_vault_cloned(
return rc == 0


def ensure_projection_cloned(
brain_path: Path,
remote_url: str,
*,
timeout: int = 60,
) -> bool:
"""Clone the brain projection into `brain_path`, adopting what is there.

On a first install `brain_path` is never empty by the time the brain
repo exists: `on_start` creates the wiki's media mountpoint inside it
before the containers start, and the repo is created after they are
up. `git clone` refuses a non-empty directory, so the directory is
turned into a working copy in place instead. Files the repo tracks
are replaced by the repo's version; untracked ones, the mountpoint
among them, stay. That is only acceptable because brain is a
projection that can be rebuilt at any time. The vault is the family's
source and keeps the strict `ensure_vault_cloned`.

Returns True when a working copy is there afterwards. On failure the
directory is left as it was found.
"""
brain_path = Path(brain_path)
if (brain_path / ".git").exists():
return True
if not brain_path.is_dir() or not any(brain_path.iterdir()):
return ensure_vault_cloned(brain_path, remote_url, timeout=timeout)

staging = brain_path.with_name(brain_path.name + ".clone")
shutil.rmtree(staging, ignore_errors=True)
rc, _, _ = _git(["git", "clone", "--no-checkout", remote_url, str(staging)], timeout=timeout)
if rc == 0:
(staging / ".git").rename(brain_path / ".git")
rc, _, _ = run_git(brain_path, "reset", "--hard", "--quiet", timeout=timeout)
if rc != 0:
shutil.rmtree(brain_path / ".git", ignore_errors=True)
shutil.rmtree(staging, ignore_errors=True)
return rc == 0


def point_remote_at(repo_path: Path, remote_url: str, *,
name: str = "origin", timeout: int = 10) -> bool:
"""Re-derive a working copy's remote URL, adding the remote if absent.
Expand Down Expand Up @@ -1354,7 +1394,7 @@ def ensure_brain_projection_admin(
brain_remote_url(code_url),
admin_user, admin_token,
)
cloned_brain = ensure_vault_cloned(brain_path, brain_remote) and not had_brain
cloned_brain = ensure_projection_cloned(brain_path, brain_remote) and not had_brain
if had_brain:
# Freshly issued token, current host URL — the clone made on
# an older lease with an older token gets both refreshed.
Expand Down Expand Up @@ -2157,7 +2197,7 @@ def install_memory_to_forgejo_admin(
brain_remote_url(code_url),
admin_user, admin_token,
)
cloned_brain = ensure_vault_cloned(brain_path, brain_remote)
cloned_brain = ensure_projection_cloned(brain_path, brain_remote)

return {
"created_org": repo_state["created_org"],
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/stacklets/test_memory_install_seeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,12 @@ def test_creates_seeds_and_clones_missing_brain(self, httpserver, tmp_path, monk

cloned = {}

def fake_clone(path, remote):
def fake_clone(path, remote, **_):
cloned["path"] = path
cloned["remote"] = remote
return True

monkeypatch.setattr(memory_lib, "ensure_vault_cloned", fake_clone)
monkeypatch.setattr(memory_lib, "ensure_projection_cloned", fake_clone)

brain = tmp_path / "brain"
result = ensure_brain_projection_admin(
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/stacklets/test_memory_token_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class _RecordingCtx:

A real `StackContext` wants an instance on disk, a parsed config and
a Docker runtime. The hook wants four things: the data dir, the
rendered env, the secret store, and somewhere to log progress.
rendered env, the secret store, and somewhere to log progress and warnings.
Standing up only those four is what lets the hook be driven end to
end here, which matters because the bug this file guards lived in
`run()` itself and not in any helper it calls.
Expand All @@ -128,6 +128,9 @@ def secret(self, name, value=None):
def step(self, message):
self.steps.append(message)

def warn(self, message):
self.steps.append(message)


@pytest.fixture
def forgejo_issuing_tokens(httpserver) -> str:
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/stacklets/test_memory_vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from lib import ( # noqa: E402
authenticated_remote,
ensure_projection_cloned,
ensure_vault_cloned,
get_ontology,
host_code_url,
Expand Down Expand Up @@ -207,6 +208,84 @@ def test_returns_false_on_bad_remote(self, tmp_path):
assert not (vault / ".git").exists()


class TestEnsureVaultClonedLeavesExistingFilesAlone:
"""The vault is the family's source: never clone over files already there."""

def test_refuses_a_directory_that_already_holds_files(self, tmp_path, seeded_upstream):
vault = tmp_path / "memory" / "vault"
vault.mkdir(parents=True)
(vault / "notes.md").write_text("typed by hand\n")

ok = ensure_vault_cloned(vault, str(seeded_upstream))

assert ok is False
assert not (vault / ".git").exists()
assert (vault / "notes.md").read_text() == "typed by hand\n"


# ─── ensure_projection_cloned ────────────────────────────────────────────
# On a first install the wiki's media mountpoint (`brain/media`) is created
# before the containers start, and the brain repo only exists once they are
# up. By the time brain is cloned its directory is therefore never empty.
# Brain is a projection that can be rebuilt at any time, so the clone
# adopts the directory instead of refusing it.

class TestEnsureProjectionCloned:

def test_adopts_a_directory_the_mountpoint_already_created(self, tmp_path, seeded_upstream):
brain = tmp_path / "memory" / "brain"
(brain / "media").mkdir(parents=True)
(brain / "media" / "photo.png").write_bytes(b"png")

ok = ensure_projection_cloned(brain, str(seeded_upstream))

assert ok is True
assert (brain / ".git").is_dir()
assert (brain / "ontology.toml").exists()
assert (brain / "media" / "photo.png").read_bytes() == b"png"

def test_the_repository_wins_over_a_page_written_before_the_clone(self, tmp_path, seeded_upstream):
brain = tmp_path / "memory" / "brain"
brain.mkdir(parents=True)
(brain / "ontology.toml").write_text("# generated before the clone\n")

assert ensure_projection_cloned(brain, str(seeded_upstream)) is True

assert "simracing" in (brain / "ontology.toml").read_text()

def test_leaves_the_working_copy_on_the_default_branch_tracking_origin(self, tmp_path, seeded_upstream):
brain = tmp_path / "memory" / "brain"
(brain / "media").mkdir(parents=True)
ensure_projection_cloned(brain, str(seeded_upstream))

_push_change(seeded_upstream, tmp_path, "later.md", "pushed later\n")

assert pull_vault(brain) is True
assert (brain / "later.md").exists()

def test_clones_normally_when_the_directory_is_missing(self, tmp_path, seeded_upstream):
brain = tmp_path / "memory" / "brain"

assert ensure_projection_cloned(brain, str(seeded_upstream)) is True
assert (brain / "ontology.toml").exists()

def test_idempotent_on_an_existing_working_copy(self, tmp_path, seeded_upstream):
brain = tmp_path / "memory" / "brain"
ensure_projection_cloned(brain, str(seeded_upstream))

assert ensure_projection_cloned(brain, str(seeded_upstream)) is True

def test_bad_remote_leaves_the_directory_as_it_was(self, tmp_path):
brain = tmp_path / "memory" / "brain"
(brain / "media").mkdir(parents=True)

ok = ensure_projection_cloned(brain, "/nonexistent/repo", timeout=5)

assert ok is False
assert not (brain / ".git").exists()
assert (brain / "media").is_dir()


# ─── pull_vault ──────────────────────────────────────────────────────────

class TestPullVault:
Expand Down
Loading