feat(host_agent): a group's updates and reboots on a weekly cadence, one host at a time (#5768)
#5768 "A maintenance cadence: a group's updates and reboots, one host at a time, on a schedule". A plan names an inventory group, a weekday and time in a time zone, a window and a reboot policy (only when required by default). At its slot a run takes the group's hosts in order and gives each a #5767 maintenance job. The next host starts only when the last is back in service: its agent is posting and, for a swarm node, the node is active and every replicated service is at its replicas. A failure stops the run, and no host is started once the window has passed. A host's latest failure shows as host_agent/<host>/maintenance_failed, for an ordinary alert rule. The page is Hosts → Maintenance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Integration (#5768): a plan updates its group's hosts one at a time; the
|
||||
second starts only once the first is back, and a failure stops the run and
|
||||
flags the host for an alert rule.
|
||||
|
||||
The plugin's cadence and models are reached through the module the app loaded
|
||||
(see test_docker_disk_page): importing the plugin's models again raises
|
||||
"Table already defined"."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
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 _ha(sub):
|
||||
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}.{sub}")
|
||||
|
||||
|
||||
async def _seed(app):
|
||||
from sqlalchemy import text
|
||||
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.users import User
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
out = {"tokens": {}, "hosts": {}}
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
user = User(username=f"cad-{tag}", email=f"cad-{tag}@test", password_hash="x")
|
||||
group = AnsibleGroup(name=f"cad-{tag}")
|
||||
s.add_all([user, group])
|
||||
await s.flush()
|
||||
for n in ("a", "b"):
|
||||
host = Host(name=f"cad-{n}-{tag}", address="10.9.9.6")
|
||||
s.add(host)
|
||||
await s.flush()
|
||||
target = AnsibleTarget(name=f"cad-{n}-{tag}", address="10.9.9.6", host_id=host.id)
|
||||
target.groups = [group]
|
||||
s.add(target)
|
||||
token = f"tok-{n}-{tag}-{uuid.uuid4().hex}"
|
||||
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(token.encode()).hexdigest()})
|
||||
out["tokens"][n], out["hosts"][n] = token, host
|
||||
plan_id = str(uuid.uuid4())
|
||||
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": plan_id, "name": f"plan-{tag}", "g": group.id, "u": user.id})
|
||||
out["plan_id"] = plan_id
|
||||
return out
|
||||
|
||||
|
||||
async def _post(app, token, events=()):
|
||||
resp = await app.test_client().post(
|
||||
"/plugins/host_agent/ingest",
|
||||
json={"samples": [{"ts": datetime.now(timezone.utc).isoformat(), "cpu_pct": 1.0}],
|
||||
"events": list(events)},
|
||||
headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200, await resp.get_data(as_text=True)
|
||||
return (await resp.get_json()).get("maintenance", [])
|
||||
|
||||
|
||||
async def _tick(app):
|
||||
cadence = _ha("cadence")
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await cadence.tick(s, datetime.now(timezone.utc))
|
||||
return await cadence.failed_hosts(s, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
async def _run(app, plan_id):
|
||||
from sqlalchemy import text
|
||||
async with app.db_sessionmaker() as s:
|
||||
return (await s.execute(text(
|
||||
"SELECT state, position, detail FROM host_maintenance_runs WHERE plan_id = :p"),
|
||||
{"p": plan_id})).one()
|
||||
|
||||
|
||||
def test_hosts_go_one_at_a_time_and_a_failure_stops_the_run():
|
||||
app = _app()
|
||||
|
||||
async def _go():
|
||||
seed = await _seed(app)
|
||||
cadence, models = _ha("cadence"), _ha("models")
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
plan = await s.get(models.HostMaintenancePlan, seed["plan_id"])
|
||||
await cadence.start_run(s, plan, datetime.now(timezone.utc), manual=True)
|
||||
await _tick(app)
|
||||
|
||||
# Only the first host (by name) has a job; the second waits.
|
||||
ta, tb = seed["tokens"]["a"], seed["tokens"]["b"]
|
||||
assert await _post(app, tb) == []
|
||||
(step,) = await _post(app, ta)
|
||||
assert step["action"] == "update"
|
||||
done = {"kind": "maintenance", "id": step["id"], "action": "update", "state": "done",
|
||||
"ok": True, "packages": 2, "reboot_required": False}
|
||||
await _post(app, ta, [done])
|
||||
|
||||
# Back in service (posting, not a swarm node): the next host starts.
|
||||
await _tick(app)
|
||||
assert (await _run(app, seed["plan_id"]))[:2] == ("running", 1)
|
||||
(step_b,) = await _post(app, tb)
|
||||
failed = {"kind": "maintenance", "id": step_b["id"], "action": "update",
|
||||
"state": "failed", "ok": False, "detail": "dpkg was interrupted"}
|
||||
await _post(app, tb, [failed])
|
||||
|
||||
flags = await _tick(app)
|
||||
state, _, detail = await _run(app, seed["plan_id"])
|
||||
assert state == "failed" and seed["hosts"]["b"].name in detail and "dpkg" in detail
|
||||
assert flags[seed["hosts"]["b"].name] == 1.0 and flags[seed["hosts"]["a"].name] == 0.0
|
||||
|
||||
asyncio.run(_go())
|
||||
Reference in New Issue
Block a user