Each weekly run drains Docker02 then Docker03, and the swarm never moves a task back by itself, so every run ended with nearly every service on one node. On 2026-10-25 the operator had that spread out by hand. - Agent 1.26.0 reports each swarm service's pinned digest, and every repo@sha256 its host holds (from /system/df, every 10 min). - Once a run's last host is back in service, Steward picks services on the fullest node to move to the emptiest until they are within one task. It takes services with several replicas on one node first. A service moves only when the emptier node already holds its exact image. One the plan keeps in place, one below its replicas, and one with no pinned digest stay put, and the run says why. - A swarm manager's helper carries it out through a new action, rebalance, with the service names (validated, at most 48). It force-updates each in turn with the spec unchanged and no registry lookup, skips any a constraint places, and gives each 5 min to settle. - The run ends done with what moved, or failed if a move didn't settle or the manager didn't answer in 60 min. A docker-scoped window covers the restarts, and the step goes on the timeline. - Plans gain "Spread swarm services back out" (on by default) and "Keep in place", on the create form and in an "After the run" form on each plan. list_maintenance_plans shows both and the latest run's spread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
8.1 KiB
Python
159 lines
8.1 KiB
Python
"""Integration (#5834): the MCP's maintenance tools against real Postgres.
|
|
|
|
A plan on a group, a host whose last job failed and one with a job running,
|
|
read back through list_maintenance_plans and get_maintenance_job; run_maintenance
|
|
refuses a busy host, starts one on a free host and audits it; the overview
|
|
names the failed job.
|
|
|
|
Rows are seeded with plain SQL so this file never imports the plugin's models
|
|
(a second import after the app loaded the plugin raises "Table already
|
|
defined"). The tool module comes from the loaded plugin.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import importlib
|
|
import os
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
if not os.environ.get("STEWARD_DATABASE_URL"):
|
|
pytest.skip("needs Postgres")
|
|
from steward.app import create_app
|
|
return create_app(testing=False)
|
|
|
|
|
|
def _tools():
|
|
from steward.core.plugin_manager import get_loaded_plugin_modules
|
|
loaded = get_loaded_plugin_modules().get("host_agent")
|
|
base = loaded.__name__ if loaded is not None else "plugins.host_agent"
|
|
return importlib.import_module(f"{base}.mcp_tools")
|
|
|
|
|
|
def test_maintenance_plans_jobs_and_starting_one_through_mcp(app):
|
|
from sqlalchemy import select, text
|
|
from steward.core.api_tokens import Caller
|
|
from steward.mcp import _context
|
|
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.hosts import Host
|
|
from steward.models.users import User, UserRole
|
|
|
|
t = _tools()
|
|
tag = uuid.uuid4().hex[:8]
|
|
ids = {}
|
|
|
|
async def _seed():
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
user = User(username=f"mm-{tag}", email=f"mm-{tag}@test", password_hash="x",
|
|
role=UserRole.admin)
|
|
group = AnsibleGroup(name=f"mm-{tag}")
|
|
s.add_all([user, group])
|
|
await s.flush()
|
|
ids["user"], ids["group"] = user.id, group.id
|
|
for n in ("failed", "busy", "free"):
|
|
host = Host(name=f"mm-{n}-{tag}", address="10.9.9.5")
|
|
s.add(host)
|
|
await s.flush()
|
|
target = AnsibleTarget(name=f"mm-{n}-{tag}", address="10.9.9.5", host_id=host.id)
|
|
target.groups = [group]
|
|
s.add(target)
|
|
await s.execute(text(
|
|
"INSERT INTO host_agent_registrations (id, host_id, token_hash, "
|
|
" token_created_at, last_seen_at, maint_helper, created_at, updated_at) "
|
|
"VALUES (:id, :h, :hash, now(), now(), true, now(), now())"),
|
|
{"id": str(uuid.uuid4()), "h": host.id,
|
|
"hash": hashlib.sha256(uuid.uuid4().hex.encode()).hexdigest()})
|
|
ids[n] = host.id
|
|
ids["plan"], ids["run"], ids["failed_job"] = (str(uuid.uuid4()) for _ in range(3))
|
|
await s.execute(text(
|
|
"INSERT INTO host_maintenance_plans (id, name, group_id, weekday, at_time, "
|
|
" time_zone, window_minutes, reboot_policy, enabled, created_by, created_at) "
|
|
"VALUES (:id, :name, :g, 6, '03:00', 'UTC', 240, 'if_required', true, :u, now())"),
|
|
{"id": ids["plan"], "name": f"plan-{tag}", "g": group.id, "u": user.id})
|
|
await s.execute(text(
|
|
"INSERT INTO host_maintenance_runs (id, plan_id, state, host_ids, position, "
|
|
" skipped, detail, manual, started_at, finished_at) "
|
|
"VALUES (:id, :p, 'failed', :hosts, 1, :skipped, 'stopped: a host failed', "
|
|
" false, now() - interval '2 hours', now() - interval '1 hour')"),
|
|
{"id": ids["run"], "p": ids["plan"],
|
|
"hosts": f'["{ids["failed"]}", "{ids["busy"]}"]',
|
|
"skipped": f'[{{"host_id": "{ids["free"]}", "reason": "no agent"}}]'})
|
|
await s.execute(text(
|
|
"INSERT INTO host_maintenance_jobs (id, host_id, state, state_at, reboot_policy, "
|
|
" step_seen, packages, reboot_needed, detail, run_id, created_at, finished_at) "
|
|
"VALUES (:id, :h, 'failed', now() - interval '1 hour', 'if_required', true, 3, "
|
|
" true, 'update failed: apt-get dist-upgrade exited 100', :run, "
|
|
" now() - interval '2 hours', now() - interval '1 hour')"),
|
|
{"id": ids["failed_job"], "h": ids["failed"], "run": ids["run"]})
|
|
await s.execute(text(
|
|
"INSERT INTO host_maintenance_jobs (id, host_id, state, state_at, reboot_policy, "
|
|
" step_seen, created_by, created_at) "
|
|
"VALUES (:id, :h, 'updating', now(), 'never', false, :u, now())"),
|
|
{"id": str(uuid.uuid4()), "h": ids["busy"], "u": user.id})
|
|
|
|
async def _go():
|
|
await _seed()
|
|
_context._app_ctx.set(app)
|
|
_context._caller_ctx.set(Caller(user_id=ids["user"], username=f"mm-{tag}",
|
|
role=UserRole.admin, token_id=9))
|
|
|
|
plans = {p["plan_id"]: p for p in (await t.list_maintenance_plans())["plans"]}
|
|
plan = plans[ids["plan"]]
|
|
assert plan["group"] == f"mm-{tag}" and plan["schedule"].startswith("Sundays at 03:00")
|
|
assert plan["next_slot_at"] and plan["enabled"] is True
|
|
assert plan["spread_after_run"] is True and plan["keep_in_place"] == []
|
|
run = plan["latest_run"]
|
|
assert run["spread"] is None # it failed before its last host
|
|
assert run["state"] == "failed" and run["hosts_done"] == 1
|
|
assert run["hosts"] == [f"mm-failed-{tag}", f"mm-busy-{tag}"]
|
|
assert run["skipped"] == [{"host": f"mm-free-{tag}", "reason": "no agent"}]
|
|
|
|
failed = (await t.get_maintenance_job(host=f"mm-failed-{tag}"))["jobs"]
|
|
assert [j["state"] for j in failed] == ["failed"]
|
|
assert failed[0]["detail"].startswith("update failed") and failed[0]["started_by"] == "maintenance plan"
|
|
by_id = (await t.get_maintenance_job(job=ids["failed_job"]))["jobs"][0]
|
|
assert by_id["host"] == f"mm-failed-{tag}"
|
|
with pytest.raises(ValueError, match="either `host`"):
|
|
await t.get_maintenance_job()
|
|
|
|
with pytest.raises(ValueError, match="already running.*Nothing was started"):
|
|
await t.run_maintenance(f"mm-busy-{tag}")
|
|
started = await t.run_maintenance(f"mm-free-{tag}", reboot="never")
|
|
assert started["state"] == "queued" and started["reboot_policy"] == "never"
|
|
[queued] = (await t.get_maintenance_job(host=ids["free"]))["jobs"]
|
|
assert queued["job_id"] == started["job_id"] and queued["started_by"] == f"mm-{tag}"
|
|
|
|
async with app.db_sessionmaker() as s:
|
|
rows = list((await s.execute(select(AuditEvent).where(
|
|
AuditEvent.user_id == ids["user"],
|
|
AuditEvent.action == "host_agent.maintenance"))).scalars())
|
|
findings = await t.mcp_overview(s)
|
|
assert len(rows) == 1 and rows[0].username == f"mm-{tag} via MCP"
|
|
mine = [f for f in findings if f["kind"] == "maintenance_failed"
|
|
and f["ids"]["host_id"] == ids["failed"]]
|
|
assert len(mine) == 1 and "apt-get dist-upgrade" in mine[0]["summary"]
|
|
assert not [f for f in findings if f["kind"] == "maintenance_failed"
|
|
and f["ids"]["host_id"] in (ids["busy"], ids["free"])]
|
|
|
|
asyncio.run(_go())
|
|
|
|
|
|
def test_the_host_agent_tools_are_listed_whatever_its_stored_switch_says(app):
|
|
"""The host agent is always on (operator, 2026-10-15): its tools stay
|
|
listed even with a stored disable."""
|
|
from steward.core.settings import to_plugins_cfg
|
|
app.config["PLUGINS"] = to_plugins_cfg({"plugin.host_agent": {"enabled": False}})
|
|
mcp = app.mcp_server
|
|
assert mcp.plugin_enabled("host_agent")
|
|
for name in ("list_agents", "list_maintenance_plans", "get_maintenance_job", "run_maintenance"):
|
|
assert mcp.tool_plugins.get(name) == "host_agent", name
|