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
251 lines
9.3 KiB
Python
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"]]
|