Files
stackpilot/backend/main.py
T
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

215 lines
7.6 KiB
Python

"""StackPilot backend — FastAPI application entry point."""
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlmodel import Session
from config import settings
from version import APP_VERSION
from database import engine, init_db
import docker_client
from docker_client import DockerError
from routers import (
audit,
auth,
backups,
containers,
dashboard,
destinations,
editor,
files,
git,
images,
networks,
oidc,
ports,
registries,
schedules,
secrets,
settings as settings_router,
stacks,
system,
templates,
tokens,
volumes,
ws,
)
from services import (
backup_destination_service,
docker_watch_service,
git_service,
image_status_store,
logo_service,
registry_service,
schedule_service,
secret_service,
stack_cleanup_service,
stack_lock_service,
template_service,
update_service,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot")
#: Workers for ``asyncio.to_thread``. The default (CPU count + 4, so six on a
#: two-core VM) is shared by everything that leaves the event loop — a backup
#: streaming several volumes, the dashboard, NFS and SFTP destinations — and
#: running out of it does not fail, it waits. Long-lived readers (the event
#: stream, terminals) have threads of their own; this is headroom for the rest.
DEFAULT_EXECUTOR_WORKERS = 64
@asynccontextmanager
async def lifespan(app: FastAPI):
asyncio.get_running_loop().set_default_executor(
ThreadPoolExecutor(max_workers=DEFAULT_EXECUTOR_WORKERS, thread_name_prefix="sp-worker")
)
init_db()
# Register stacks that already exist on disk.
try:
with Session(engine) as session:
stacks.sync_discovered_stacks(session)
orphans = stack_cleanup_service.prune_orphans(session)
if orphans:
logger.info("Forgot Git sources/policies of deleted stack(s): %s", ", ".join(orphans))
except Exception as exc: # noqa: BLE001
logger.warning("Stack discovery failed: %s", exc)
# One-off: encrypt backup-destination credentials written before they were
# stored encrypted (see services/crypto_service.py).
try:
with Session(engine) as session:
encrypted = backup_destination_service.migrate_plaintext_configs(session)
if encrypted:
logger.info("Encrypted %d backup destination config(s) at rest", encrypted)
except Exception as exc: # noqa: BLE001
logger.warning("Destination config encryption migration failed: %s", exc)
try:
fixed = secret_service.fix_permissions()
if fixed:
logger.info("Made %d stack secret file(s) readable for non-root containers", fixed)
except Exception as exc: # noqa: BLE001
logger.warning("Could not fix secret permissions: %s", exc)
try:
moved = template_service.migrate_legacy_db_templates()
if moved:
logger.info("Migrated %d custom template(s) from the database to folders", moved)
except Exception as exc: # noqa: BLE001
logger.warning("Legacy template migration failed: %s", exc)
# Throwaway containers a previous run left behind (a helper whose start
# failed, e.g. on an NFS mount, used to stay in "Created" and keep its
# volume in use). Nothing of ours is in flight yet, so all can go.
try:
client = docker_client.get_client()
cleared = docker_client.remove_stale_helpers(client)
cleared += backup_destination_service.clear_nfs_leftovers(client)
if cleared:
logger.info("Removed %d leftover helper container(s)", cleared)
except Exception as exc: # noqa: BLE001 - Docker may not be up yet
logger.warning("Could not clean up helper containers: %s", exc)
# Runtime state that used to live in module dicts and was lost on restart.
# Stack locks mean "an operation is running"; after a restart none is, so
# all of them go — not just the expired ones. A backup holds its lock for
# twelve hours, and one that died with its process used to leave the stack
# un-startable for the rest of them.
try:
with Session(engine) as session:
stale = stack_lock_service.clear_all(session)
if stale:
logger.info(
"Cleared %d stack lock(s) left over from a previous run: %s",
len(stale), ", ".join(sorted(stale)),
)
except Exception as exc: # noqa: BLE001
logger.warning("Could not clear stack locks: %s", exc)
try:
restored = image_status_store.install()
logger.info("Restored %d cached image update status(es)", restored)
except Exception as exc: # noqa: BLE001
logger.warning("Could not restore the image update cache: %s", exc)
# Private registry credentials: into the in-memory cache the update checker
# reads, and into the config.json the Docker CLI reads.
try:
with Session(engine) as session:
known = registry_service.reload(session)
if known:
logger.info("Loaded credentials for %d registr%s", known, "y" if known == 1 else "ies")
except Exception as exc: # noqa: BLE001
logger.warning("Could not load registry credentials: %s", exc)
update_task = asyncio.create_task(update_service.background_loop())
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
# App logos. Deliberately a task and not awaited: the catalog is a network
# download, and a box with no outbound internet must still start instantly
# (it just keeps the built-in glyphs).
logo_task = asyncio.create_task(logo_service.catalog_loop())
git_service.ensure_cache_root()
git_task = asyncio.create_task(git_service.poll_loop())
# Keeps the server-side caches in step with changes made outside StackPilot.
docker_watch_service.start()
logger.info("StackPilot backend ready on port %s", settings.PORT)
yield
docker_watch_service.stop()
update_task.cancel()
schedule_task.cancel()
logo_task.cancel()
git_task.cancel()
app = FastAPI(title="StackPilot", version=APP_VERSION, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(DockerError)
async def docker_error_handler(_request: Request, exc: DockerError):
return JSONResponse(
status_code=502,
content={"error": exc.error, "detail": exc.detail},
)
app.include_router(auth.router)
app.include_router(stacks.router)
app.include_router(git.router)
app.include_router(git.hook_router)
app.include_router(oidc.router)
app.include_router(tokens.router)
app.include_router(registries.router)
app.include_router(secrets.router)
app.include_router(containers.router)
app.include_router(dashboard.router)
app.include_router(system.router)
app.include_router(volumes.router)
app.include_router(editor.router)
app.include_router(files.router)
app.include_router(images.router)
app.include_router(ports.router)
app.include_router(templates.router)
app.include_router(audit.router)
app.include_router(settings_router.router)
app.include_router(backups.router)
app.include_router(destinations.router)
app.include_router(schedules.router)
app.include_router(networks.router)
app.include_router(ws.router)
@app.get("/api/health")
def health() -> dict:
return {"status": "ok", "version": APP_VERSION}