Files
menzeljandClaude Opus 5 51d1998307
CI / check (push) Successful in 7m17s
CI / build-and-push (push) Successful in 1m45s
Remove the remote-host (agent) integration (0.48.0)
StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.

Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.

Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.

Two things the removal exposed as dead weight rather than merely unused:

compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.

The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.

Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.

Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.

CI no longer builds or pushes stackpilot-agent.

735 tests pass, ruff and tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
2026-08-31 14:11:54 +02:00

156 lines
6.2 KiB
Python

"""SQLModel database setup."""
from __future__ import annotations
import logging
import os
from collections.abc import Generator
from typing import Optional
from sqlalchemy import inspect, text
from sqlalchemy.exc import OperationalError
from sqlmodel import Session, SQLModel, create_engine
from config import settings
logger = logging.getLogger("stackpilot.database")
os.makedirs(settings.DATA_DIR, exist_ok=True)
_DB_PATH = os.path.join(settings.DATA_DIR, "stackpilot.db")
_DB_URL = f"sqlite:///{_DB_PATH}"
engine = create_engine(
_DB_URL,
echo=False,
connect_args={"check_same_thread": False},
)
def _default_literal(col) -> Optional[str]:
"""SQL literal for a column's scalar default, or None if it has none.
Only plain values are rendered — a callable default (``default_factory``,
e.g. a timestamp) has no fixed literal, so those columns are added nullable
as before and filled by the ORM on the next write.
"""
default = col.default
if default is None or not getattr(default, "is_scalar", False):
return None
value = default.arg
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
escaped = value.replace("'", "''")
return f"'{escaped}'"
return None
def _ensure_model_columns() -> None:
"""Add columns that models define but a pre-existing table is missing.
``SQLModel.create_all`` creates missing *tables* but never ALTERs an
existing one, so installs that predate a newly-added column keep the old
schema — and every ORM query that names the column fails with
``OperationalError: no such column``. For each mapped table we diff the
model's columns against the live table and ``ADD COLUMN`` the safe
(nullable, or defaulted) ones. Idempotent: on a fresh DB create_all already
made every column, so this is a no-op.
Requires ``models`` to have been imported, or ``SQLModel.metadata`` is empty
and this silently does nothing. :func:`init_db` imports it first.
A column with a scalar default is added ``NOT NULL DEFAULT <value>`` so
existing rows are backfilled in the same statement. Without that clause
SQLite fills them with NULL, which is how a new non-nullable field turns
into a runtime surprise — for ``User.token_version`` it would have meant
every existing session failing its version check after the upgrade.
"""
insp = inspect(engine)
live_tables = set(insp.get_table_names())
with engine.begin() as conn:
for table_name, table in SQLModel.metadata.tables.items():
if table_name not in live_tables:
continue
existing = {c["name"] for c in insp.get_columns(table_name)}
for col in table.columns:
if col.name in existing:
continue
# SQLite can only ADD a NOT NULL column if it has a default to
# backfill existing rows; skip the rest rather than crash.
if not col.nullable and col.default is None and col.server_default is None:
logger.warning(
"Cannot auto-add non-nullable column %s.%s (no default); "
"manual migration needed", table_name, col.name
)
continue
ddl = f'ALTER TABLE "{table_name}" ADD COLUMN "{col.name}" '
ddl += col.type.compile(dialect=engine.dialect)
if (literal := _default_literal(col)) is not None:
# Backfills existing rows and satisfies SQLite's rule that a
# NOT NULL column may only be added together with a default.
ddl += f" NOT NULL DEFAULT {literal}"
conn.execute(text(ddl))
logger.info("Schema migration: added column %s.%s", table_name, col.name)
#: Tables and columns left behind when the remote-host (agent) integration was
#: removed in 0.48.0. SQLite before 3.35 cannot DROP COLUMN, and the rows are
#: harmless dead weight either way — so the table goes and the columns are only
#: dropped where the SQLite build supports it.
_REMOVED_TABLES = ("agent",)
_REMOVED_COLUMNS = (("autoupdate", "agent_id"), ("backupschedule", "agent_id"))
def _drop_removed_schema() -> None:
"""Clean up schema left over from features that no longer exist.
Without this an upgraded install keeps an ``agent`` table full of host URLs
and bearer tokens for a feature that is gone — credentials sitting in the
database with nothing to use them.
Each statement runs in its own transaction on purpose: a failed DDL poisons
the transaction it is in, so sharing one would mean a single unsupported
DROP COLUMN takes the table drop down with it.
"""
insp = inspect(engine)
live = set(insp.get_table_names())
for table in _REMOVED_TABLES:
if table not in live:
continue
with engine.begin() as conn:
conn.execute(text(f'DROP TABLE "{table}"'))
logger.info("Schema migration: dropped obsolete table %s", table)
for table, column in _REMOVED_COLUMNS:
if table not in live:
continue
if column not in {c["name"] for c in insp.get_columns(table)}:
continue
try:
with engine.begin() as conn:
conn.execute(text(f'ALTER TABLE "{table}" DROP COLUMN "{column}"'))
logger.info("Schema migration: dropped obsolete column %s.%s", table, column)
except OperationalError:
# SQLite < 3.35 has no DROP COLUMN. The column is nullable and
# nothing reads it any more, so leaving it is harmless.
logger.info(
"Leaving obsolete column %s.%s in place (this SQLite cannot "
"drop columns); it is unused", table, column
)
def init_db() -> None:
# Import models so they are registered on SQLModel.metadata.
import models # noqa: F401
SQLModel.metadata.create_all(engine)
_ensure_model_columns()
_drop_removed_schema()
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session