Files
stackpilot/backend/tests/test_walkthrough_fixes.py
menzeljandClaude Opus 5.5 06234c1067
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s
Run compose like the terminal does, and follow changes made there (0.67.2)
StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.

Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.

Changes made outside StackPilot now reach every view:

- docker_watch_service reads Docker's events once for the backend. The
  dashboard rollup (cached 25s) and the stats cache are stale as soon as
  the event generation moves; the tiles followed a terminal `compose
  stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
  a terminal clears the update badge instead of leaving it until the
  next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
  while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
  saving after they changed on disk is refused with 409 and the editor
  offers to overwrite or to load the version on disk. A save used to
  replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
  passing for an ordinary stopped stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
2026-10-15 15:14:40 +02:00

251 lines
9.3 KiB
Python

"""Found clicking through the UI for 0.67.1.
* The file browser opens at ``/``, which the default roots (/mnt, /opt, …)
do not include: the page opened on "outside the allowed browse roots" and
offered no way into any root. Paths above the roots now list the way to them.
* A root could not be deleted, but it could be moved — the whole tree with it.
* Locally built images (compose ``build:``) were checked against Docker Hub,
which answers 401 for a repository that does not exist: "needs credentials".
* Volumes, networks and images of a stack whose folder name compose had to
normalise ("Nextcloud" -> project "nextcloud") were grouped as leftovers of a
removed stack.
"""
from __future__ import annotations
import asyncio
import pytest
@pytest.fixture
def roots(tmp_path, monkeypatch):
from config import settings
(tmp_path / "srv" / "data").mkdir(parents=True)
(tmp_path / "opt").mkdir()
monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", str(tmp_path), raising=False)
monkeypatch.setattr(
settings, "ALLOWED_BROWSE_ROOTS", ["/opt", "/srv/data", "/media"], raising=False
)
return tmp_path
def test_the_top_of_the_tree_lists_the_way_to_the_roots(roots):
from services import device_service
top = device_service.browse("/")
assert top["virtual"] is True
# /media does not exist on this host, so it is not offered.
assert [e["name"] for e in top["entries"]] == ["opt", "srv"]
assert [e["name"] for e in device_service.browse("/srv")["entries"]] == ["data"]
inside = device_service.browse("/srv/data")
assert "virtual" not in inside and inside["parent"] == "/srv"
def test_a_path_leading_nowhere_is_still_refused(roots):
from services import device_service
with pytest.raises(device_service.BrowseError):
device_service.browse("/etc")
def test_a_root_cannot_be_moved_or_renamed(roots):
from services import file_service
with pytest.raises(file_service.BrowseError, match="browse root"):
file_service.move("/srv/data", "/opt")
with pytest.raises(file_service.BrowseError, match="browse root"):
file_service.rename("/opt", "opt2")
assert (roots / "srv" / "data").is_dir()
def test_moving_inside_a_root_still_works(roots):
from services import file_service
(roots / "opt" / "a.txt").write_text("x")
file_service.move("/opt/a.txt", "/srv/data")
assert (roots / "srv" / "data" / "a.txt").read_text() == "x"
@pytest.mark.parametrize(
"attrs, local",
[
({"Identity": {"Build": [{"Ref": "x"}]}, "RepoDigests": ["app@sha256:1"]}, True),
({"Identity": {"Pull": [{"Repository": "docker.io/library/nginx"}]}}, False),
({"RepoDigests": []}, True), # older daemon, never pulled
({"RepoDigests": ["nginx@sha256:1"]}, False),
({"Identity": {}, "RepoDigests": ["nginx@sha256:1"]}, False),
(None, False),
],
)
def test_where_an_image_came_from(attrs, local):
from services import update_service
assert update_service._built_locally(attrs) is local
def test_a_local_build_is_not_asked_about_at_the_registry(monkeypatch):
from services import update_service
monkeypatch.setattr(
update_service, "_local_attrs", lambda image: {"Identity": {"Build": [{"Ref": "x"}]}}
)
async def no_registry(image):
raise AssertionError("registry contacted for a local build")
monkeypatch.setattr(update_service, "remote_digest", no_registry)
status = asyncio.run(update_service.check_image("myapp-web:latest"))
assert status.local is True and status.error is None and not status.update_available
def test_resources_of_a_normalised_stack_are_grouped_under_it(as_admin, db, monkeypatch):
from sqlmodel import Session
from database import engine
from models.stack import Stack
from services import network_service, volume_service
with Session(engine) as session:
if not session.get(Stack, "Nextcloud"):
session.add(Stack(id="Nextcloud", name="Nextcloud"))
session.commit()
monkeypatch.setattr(
volume_service, "list_volumes", lambda: [{"name": "nextcloud_db", "stack": "nextcloud"}]
)
monkeypatch.setattr(
network_service,
"list_networks",
lambda: [{"name": "x", "stack": "elsewhere"}, {"name": "bridge", "stack": None}],
)
assert as_admin.get("/api/volumes").json()[0]["stack"] == "Nextcloud"
assert [n["stack"] for n in as_admin.get("/api/networks").json()] == ["elsewhere", None]
class _Container:
def __init__(self, mounts):
self.attrs = {"Mounts": mounts}
@pytest.mark.parametrize(
"mounts, host",
[
# The usual: same path on both sides — no warning.
([{"Type": "bind", "Source": "/opt/stacks", "Destination": "/opt/stacks"}], None),
# A real mismatch.
(
[{"Type": "bind", "Source": "/srv/compose", "Destination": "/opt/stacks"}],
"/srv/compose",
),
# The folder above STACKS_DIR is what is mounted.
([{"Type": "bind", "Source": "/srv", "Destination": "/opt"}], "/srv/stacks"),
# Not mounted at all.
([{"Type": "volume", "Source": "/var/lib/docker/volumes/x", "Destination": "/data"}], None),
],
)
def test_the_host_stacks_path_comes_from_docker_not_mountinfo(monkeypatch, mounts, host):
"""mountinfo's root field is relative to the filesystem: a stacks folder on
its own disk or btrfs subvolume showed a mismatch that was not there."""
from config import settings
from services import self_update_service, stack_assets_service
monkeypatch.setattr(settings, "STACKS_DIR", "/opt/stacks")
monkeypatch.setattr(stack_assets_service, "_HOST_STACKS_DIR", None)
monkeypatch.setattr(self_update_service, "_own_container", lambda: _Container(mounts))
mismatch = stack_assets_service.stacks_path_mismatch()
assert (mismatch["host"] if mismatch else None) == host
def test_backup_names_carry_the_time_on_the_users_clock(monkeypatch):
"""Named in UTC, a backup taken at 12:21 in Berlin read as …-102154."""
from datetime import datetime, timezone
from services import backup_service, compose_service
monkeypatch.setattr(
compose_service, "now", lambda: datetime(2026, 10, 15, 10, 21, 54, tzinfo=timezone.utc)
)
berlin = backup_service.backup_filename("app", True, tz="Europe/Berlin")
assert berlin == "backup-app-full-20261015-122154.tar.gz"
# No zone, or one that does not exist: UTC, as before.
assert backup_service.backup_filename("app", False).endswith("-20261015-102154.tar.gz")
assert backup_service.backup_filename("app", False, tz="Mars/Olympus").endswith("-102154.tar.gz")
# Retention still recognises the files.
assert backup_service.backup_name_pattern("app").match(berlin)
COMPOSE_WITH_SECRETS = """services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: hunter2
POSTGRES_USER: app
API_KEY: ${API_KEY:-changeme}
DB_PASS: ${DB_PASS}
KEYCLOAK_ADMIN: admin
app:
environment:
- "TS_AUTHKEY=tskey-123"
- TZ=Europe/Berlin
"""
def test_inline_credentials_are_masked_and_the_rest_left_alone():
from services.compose_service import MASK, mask_secrets
masked = mask_secrets(COMPOSE_WITH_SECRETS)
assert "hunter2" not in masked and "tskey-123" not in masked and "changeme" not in masked
assert f"POSTGRES_PASSWORD: {MASK}" in masked
assert f'- "TS_AUTHKEY={MASK}"' in masked
# A bare variable is no secret; names that merely contain "key" are not either.
for kept in ("DB_PASS: ${DB_PASS}", "KEYCLOAK_ADMIN: admin", "POSTGRES_USER: app", "TZ=Europe/Berlin"):
assert kept in masked
assert masked.count("\n") == COMPOSE_WITH_SECRETS.count("\n")
def test_the_read_only_role_gets_the_masked_compose_file(as_admin, as_user, db):
from services import compose_service
assert as_admin.post("/api/stacks", json={"name": "masked", "yaml": COMPOSE_WITH_SECRETS}).status_code == 201
try:
assert "hunter2" in as_admin.get("/api/stacks/masked").json()["yaml"]
assert "hunter2" not in as_user.get("/api/stacks/masked").json()["yaml"]
finally:
from sqlmodel import Session
from database import engine
from models.stack import Stack
compose_service.delete_stack_files("masked")
with Session(engine) as session:
session.delete(session.get(Stack, "masked"))
session.commit()
def test_scan_all_covers_only_stackpilots_own_stacks(as_admin, db, monkeypatch):
from sqlmodel import Session
from database import engine
from models.stack import Stack
from services import image_service, scan_service
with Session(engine) as session:
if not session.get(Stack, "Mine"):
session.add(Stack(id="Mine", name="Mine"))
session.commit()
monkeypatch.setattr(
image_service,
"list_images",
lambda: [
{"tag": "mine:1", "stacks": ["mine"]},
{"tag": "theirs:1", "stacks": ["some-other-project"]},
{"tag": "loose:1", "stacks": []},
],
)
swept = []
monkeypatch.setattr(
scan_service, "start_sweep", lambda images, on_done=None: swept.append(images) or {"active": True}
)
assert as_admin.post("/api/images/scan-all?background=true").status_code == 202
assert swept == [["mine:1"]]