feat(maintenance): a run spreads swarm services back out after its last host, moving only what the emptier node can start (#6398)
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>
This commit is contained in:
@@ -22,7 +22,7 @@ from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
|
||||
from datetime import datetime, timezone
|
||||
|
||||
AGENT_VERSION = "1.25.0"
|
||||
AGENT_VERSION = "1.26.0"
|
||||
|
||||
# Default path to the local Docker Engine socket. Overridable via the
|
||||
# `docker_socket` config key; collection is silently skipped if it's absent or
|
||||
@@ -585,6 +585,9 @@ DOCKER_DEADLINE_SECONDS = 20.0
|
||||
# /system/df walks every image, container and volume: asked this often, not
|
||||
# every sample. Steward keeps the last answer between reports.
|
||||
DOCKER_DF_INTERVAL_SECONDS = 600.0
|
||||
# How many of a host's image digests a report carries: what Steward checks a
|
||||
# swarm service's pinned image against before moving it to this node.
|
||||
DOCKER_DIGESTS_MAX = 400
|
||||
|
||||
# Container-log collection (m79). Logs ride in the same push as metrics, so one
|
||||
# interval's batch is capped to keep a chatty container from bloating a POST (and
|
||||
@@ -1251,11 +1254,14 @@ def _swarm_services(services: list, tasks: list) -> list:
|
||||
desired = (mode_obj.get("Replicated") or {}).get("Replicas", 0) or 0
|
||||
image = (((spec.get("TaskTemplate") or {}).get("ContainerSpec") or {})
|
||||
.get("Image") or "")
|
||||
image = image.split("@", 1)[0] # drop the @sha256:… digest suffix
|
||||
image, _, digest = image.partition("@") # the @sha256:… digest apart
|
||||
out.append({
|
||||
"service_name": name, "mode": mode,
|
||||
"desired": desired, "running": running_by_svc.get(sid, 0),
|
||||
"image": image,
|
||||
# The digest a stack deploy pinned, which a node must hold to start
|
||||
# the service without the registry.
|
||||
"image_digest": digest if digest.startswith("sha256:") else None,
|
||||
# task→node placement of the running replicas (cross-node — a manager
|
||||
# sees every task, even ones whose containers live on other hosts).
|
||||
"placement": [{"node_id": n, "running": cnt}
|
||||
@@ -1383,6 +1389,10 @@ def collect_disk_usage(socket_path: str):
|
||||
"volumes_size": sum(_vol_size(v) for v in volumes if _vol_size(v) > 0),
|
||||
"build_cache_size": sum(int(b.get("Size") or 0) for b in build_cache),
|
||||
"images": _disk_images(images),
|
||||
# Every image this host holds, by repository digest ("repo@sha256:…"),
|
||||
# so Steward can tell whether a swarm service could start here.
|
||||
"digests": sorted({d for im in images for d in (im.get("RepoDigests") or [])
|
||||
if isinstance(d, str) and "@sha256:" in d})[:DOCKER_DIGESTS_MAX],
|
||||
}
|
||||
|
||||
|
||||
@@ -1667,13 +1677,13 @@ def collect_reboot(path: str = REBOOT_REQUIRED, modules: str = KERNEL_MODULES):
|
||||
# The agent can't update or reboot its host: it runs unprivileged under
|
||||
# NoNewPrivileges. It leaves a request in MAINT_REQUESTS, the one directory its
|
||||
# unit lets it write; steward-maint.path starts the root helper (maint.py),
|
||||
# which does only check / update / reboot / drain / undrain and writes each
|
||||
# which does only check / update / reboot / drain / undrain / rebalance and writes each
|
||||
# outcome to MAINT_RESULTS. The agent reads those back and reports each new state once.
|
||||
|
||||
MAINT_REQUESTS = "/var/lib/steward-agent/requests"
|
||||
MAINT_RESULTS = "/var/lib/steward-maint/results"
|
||||
MAINT_PATH_UNIT = "/etc/systemd/system/steward-maint.path"
|
||||
MAINT_ACTIONS = ("check", "update", "reboot", "drain", "undrain", "drives")
|
||||
MAINT_ACTIONS = ("check", "update", "reboot", "drain", "undrain", "drives", "rebalance")
|
||||
MAINT_RESULT_MAX_BYTES = 8192
|
||||
# The helper keeps results a week; only this recent a one is reported, so a
|
||||
# job a reboot interrupted still reaches Steward from the agent's next start
|
||||
@@ -1683,7 +1693,9 @@ _MAINT_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||
_MAINT_FIELDS = ("action", "state", "ok", "packages", "reboot_required", "node",
|
||||
"detail", "started_at", "finished_at",
|
||||
# a check's answer (#6098)
|
||||
"pending", "docker_update", "docker_packages", "containers")
|
||||
"pending", "docker_update", "docker_packages", "containers",
|
||||
# a rebalance's answer
|
||||
"moved", "left")
|
||||
|
||||
|
||||
def maint_helper_installed(unit: str = MAINT_PATH_UNIT,
|
||||
@@ -1693,14 +1705,17 @@ def maint_helper_installed(unit: str = MAINT_PATH_UNIT,
|
||||
|
||||
|
||||
def request_maintenance(job_id: str, action: str, node: str | None = None,
|
||||
requests_dir: str = MAINT_REQUESTS) -> None:
|
||||
requests_dir: str = MAINT_REQUESTS, services=None) -> None:
|
||||
"""Leave the helper a request, whole: written to a dot-file the path unit
|
||||
doesn't match, then renamed to `<id>.json`."""
|
||||
doesn't match, then renamed to `<id>.json`. A rebalance names the swarm
|
||||
services to move; the helper checks each name itself."""
|
||||
if action not in MAINT_ACTIONS or not _MAINT_ID.match(job_id or ""):
|
||||
raise ValueError(f"not a maintenance request: {action!r} {job_id!r}")
|
||||
req = {"id": job_id, "action": action}
|
||||
if node:
|
||||
req["node"] = node
|
||||
if action == "rebalance":
|
||||
req["services"] = [s for s in services if isinstance(s, str)] if isinstance(services, list) else []
|
||||
tmp = os.path.join(requests_dir, f".{job_id}.tmp")
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(req, fh)
|
||||
@@ -1718,7 +1733,8 @@ def take_maintenance(steps, requested: set, requests_dir: str = MAINT_REQUESTS)
|
||||
continue
|
||||
try:
|
||||
request_maintenance(str(step.get("id") or ""), str(step.get("action") or ""),
|
||||
step.get("node"), requests_dir=requests_dir)
|
||||
step.get("node"), requests_dir=requests_dir,
|
||||
services=step.get("services"))
|
||||
except (ValueError, OSError) as exc:
|
||||
_log("WARN", f"maintenance step {step.get('id')!r} not taken: {exc}")
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,8 @@ active, every replicated service at its replicas. A job that fails, or a host
|
||||
that doesn't come back into service, stops the run, and the host's
|
||||
maintenance_failed figure goes to 1 for an alert rule to catch. No host is
|
||||
started after the window; the run then stops and says which hosts it didn't
|
||||
reach.
|
||||
reach. A run that drained swarm nodes ends by spreading the swarm's services
|
||||
back out (rebalance.py), unless its plan says not to.
|
||||
|
||||
The decisions are model-free here (plans and runs are anything with the
|
||||
attributes); the database side is below them, and the plugin's minute tick
|
||||
@@ -22,7 +23,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
from . import maintenance, timeline
|
||||
from . import maintenance, rebalance, timeline
|
||||
|
||||
WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||
# How long a host may take to be back in service after its job finished.
|
||||
@@ -244,11 +245,14 @@ async def advance(session, run, plan, now: datetime) -> str:
|
||||
await timeline.write(session, f"maintenance job {job.id}",
|
||||
timeline.job_entry(session, job, host.name if host else None))
|
||||
name = host.name if host else host_id
|
||||
_end(run, "failed", now, f"{name}: {why}")
|
||||
_end(run, "failed", f"{name}: {why}", now=now)
|
||||
elif move == "stop":
|
||||
_end(run, "stopped", now, why)
|
||||
_end(run, "stopped", why, now=now)
|
||||
elif move == "done":
|
||||
_end(run, "done", now, None)
|
||||
if await rebalance.begin(session, run, plan, now) or \
|
||||
(run.rebalance or {}).get("state") == "handed":
|
||||
return run.state # a manager is spreading services back out
|
||||
_end(run, *rebalance.outcome(run), now=now)
|
||||
return run.state
|
||||
return run.state
|
||||
|
||||
@@ -262,7 +266,7 @@ def _skip(run, host_id: str, reason: str) -> None:
|
||||
run.host_ids = json.dumps(hosts)
|
||||
|
||||
|
||||
def _end(run, state: str, now: datetime, detail: str | None) -> None:
|
||||
def _end(run, state: str, detail: str | None = None, *, now: datetime) -> None:
|
||||
run.state, run.finished_at, run.settle_since = state, now, None
|
||||
if detail:
|
||||
run.detail = detail[:maintenance.DETAIL_MAX]
|
||||
@@ -289,6 +293,8 @@ async def tick(session, now: datetime) -> list[str]:
|
||||
plan = await session.get(HostMaintenancePlan, run.plan_id)
|
||||
if plan is None:
|
||||
continue
|
||||
if rebalance.check_deadline(run, now):
|
||||
await rebalance.finish(session, run, now)
|
||||
before = run.state
|
||||
after = await advance(session, run, plan, now)
|
||||
if after != before:
|
||||
|
||||
@@ -8,7 +8,8 @@ REQUESTS, the one directory it may write. This helper reads each request
|
||||
without following links, refuses anything outside the list, deletes it, does
|
||||
the action, and writes the outcome to RESULTS (root's own directory, which the
|
||||
agent reads back and reports to Steward). A stolen agent token can therefore
|
||||
make its host update, reboot or drain, and nothing else.
|
||||
make its host update, reboot or drain, or on a swarm manager restart named
|
||||
services where the swarm puts them, and nothing else.
|
||||
|
||||
One action only reads: `drives` (#5789) asks smartctl for each disk's health,
|
||||
which needs root, and writes a summary to DRIVES, never a result: it is not a
|
||||
@@ -16,6 +17,12 @@ maintenance job. `check` (#6098) changes nothing but the package lists: it
|
||||
says what an update would upgrade, whether Docker is among it, and which
|
||||
containers run outside the swarm.
|
||||
|
||||
`rebalance` spreads swarm services back out after a maintenance run left them
|
||||
on one node. Steward names the services, having checked that the emptier node
|
||||
holds each one's image; the helper force-updates them one at a time, spec
|
||||
unchanged and no image pulled, so the swarm places each on its emptiest node.
|
||||
A service a constraint or a preference places is left alone.
|
||||
|
||||
Stdlib only; served by Steward at /plugins/host_agent/files/maint.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -32,7 +39,7 @@ from datetime import datetime, timezone
|
||||
REQUESTS = "/var/lib/steward-agent/requests"
|
||||
RESULTS = "/var/lib/steward-maint/results"
|
||||
DRIVES = "/var/lib/steward-maint/drives.json"
|
||||
ACTIONS = ("check", "update", "reboot", "drain", "undrain", "drives")
|
||||
ACTIONS = ("check", "update", "reboot", "drain", "undrain", "drives", "rebalance")
|
||||
REQUEST_MAX_BYTES = 4096
|
||||
RESULTS_KEPT_SECONDS = 7 * 86400
|
||||
UPDATE_DEADLINE_SECONDS = 3000
|
||||
@@ -42,6 +49,12 @@ OUTPUT_KEPT_CHARS = 2000
|
||||
LISTING_KEPT_CHARS = 1_000_000
|
||||
_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
||||
_NODE = re.compile(r"^[a-z0-9]{25}$")
|
||||
# A swarm service's name as Docker allows it, never a flag.
|
||||
_SERVICE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$")
|
||||
SERVICES_MAX = 48
|
||||
MOVE_DEADLINE_SECONDS = 300
|
||||
# A rebalance starts no move after this long.
|
||||
REBALANCE_SECONDS = 2400
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -70,12 +83,19 @@ def read_request(path: str):
|
||||
|
||||
|
||||
def valid(req) -> bool:
|
||||
"""A request names a known action, has a plain id, and a node id only in
|
||||
the swarm's own form."""
|
||||
"""A request names a known action, has a plain id, a node id only in the
|
||||
swarm's own form, and, for a rebalance alone, a list of service names."""
|
||||
if not isinstance(req, dict) or req.get("action") not in ACTIONS:
|
||||
return False
|
||||
if not isinstance(req.get("id"), str) or not _ID.match(req["id"]):
|
||||
return False
|
||||
services = req.get("services")
|
||||
if req["action"] == "rebalance":
|
||||
if not (isinstance(services, list) and 0 < len(services) <= SERVICES_MAX
|
||||
and all(isinstance(s, str) and _SERVICE.match(s) for s in services)):
|
||||
return False
|
||||
elif services is not None:
|
||||
return False
|
||||
node = req.get("node")
|
||||
return node is None or (isinstance(node, str) and bool(_NODE.match(node)))
|
||||
|
||||
@@ -344,6 +364,52 @@ def do_node(availability: str, node: str | None) -> dict:
|
||||
return {"ok": ok, "node": node, "detail": out.strip()[-500:]}
|
||||
|
||||
|
||||
def placed_by_rule(name: str) -> bool | None:
|
||||
"""Whether a constraint or a preference decides where the service runs
|
||||
(a force-update would leave it where it is); None when it can't be read."""
|
||||
ok, out = run(["docker", "service", "inspect", "--format",
|
||||
"{{json .Spec.TaskTemplate.Placement}}", name], deadline=30)
|
||||
if not ok:
|
||||
return None
|
||||
try:
|
||||
placement = json.loads(out.strip() or "null") or {}
|
||||
except ValueError:
|
||||
return None
|
||||
return bool(placement.get("Constraints") or placement.get("Preferences"))
|
||||
|
||||
|
||||
def do_rebalance(services: list, clock=time.monotonic) -> dict:
|
||||
"""Force-update each named service in turn, waiting for it to settle, so
|
||||
the swarm starts it again on its emptiest node. Fails when a move didn't
|
||||
settle; what was left and why is in `left`."""
|
||||
if not shutil.which("docker"):
|
||||
return {"ok": False, "detail": "no docker on this host"}
|
||||
began = clock()
|
||||
moved, left, failed = [], [], []
|
||||
for name in services:
|
||||
if clock() - began > REBALANCE_SECONDS:
|
||||
left.append({"service": name, "reason": "out of time"})
|
||||
continue
|
||||
rule = placed_by_rule(name)
|
||||
if rule is None:
|
||||
left.append({"service": name, "reason": "not found on this manager"})
|
||||
continue
|
||||
if rule:
|
||||
left.append({"service": name, "reason": "placed by a constraint"})
|
||||
continue
|
||||
ok, out = run(["docker", "service", "update", "--force", "--detach=false", "--quiet", name],
|
||||
deadline=MOVE_DEADLINE_SECONDS)
|
||||
if ok:
|
||||
moved.append(name)
|
||||
else:
|
||||
failed.append(name)
|
||||
left.append({"service": name, "reason": f"didn't settle: {out.strip()[-120:]}"})
|
||||
detail = f"moved {len(moved)} of {len(services)}"
|
||||
if failed:
|
||||
detail += "; didn't settle: " + ", ".join(failed)
|
||||
return {"ok": not failed, "moved": moved, "left": left, "detail": detail}
|
||||
|
||||
|
||||
# ── drive health (#5789) ─────────────────────────────────────────────────────
|
||||
# smartctl ≥ 7.0 answers in JSON, the same for SATA, SAS and NVMe drives on any
|
||||
# distribution; an older one is reported, never parsed as text. The helper
|
||||
@@ -509,6 +575,8 @@ def handle(req: dict) -> dict:
|
||||
outcome = do_update()
|
||||
elif action in ("drain", "undrain"):
|
||||
outcome = do_node("drain" if action == "drain" else "active", req.get("node"))
|
||||
elif action == "rebalance":
|
||||
outcome = do_rebalance(req["services"])
|
||||
else: # reboot
|
||||
result = {**base, "state": "rebooting", "ok": True, "finished_at": now_iso()}
|
||||
write_result(result)
|
||||
|
||||
@@ -409,8 +409,9 @@ async def apply_events(session, host_id: str, events, now: datetime) -> int:
|
||||
"""Apply one post's `maintenance` results and `started` boots to the jobs
|
||||
they belong to; returns how many jobs changed. A result is matched by its
|
||||
step id, so a drain reported by a manager reaches the job of the node it
|
||||
drained."""
|
||||
drained. A result no job owns may be a run's rebalance (rebalance.py)."""
|
||||
from sqlalchemy import select
|
||||
from . import rebalance
|
||||
from .downtime import _at
|
||||
from .models import HostMaintenanceJob
|
||||
if not isinstance(events, list):
|
||||
@@ -425,6 +426,8 @@ async def apply_events(session, host_id: str, events, now: datetime) -> int:
|
||||
HostMaintenanceJob.state.in_(ACTIVE)))).scalar_one_or_none()
|
||||
if job is not None and apply_result(job, event, now):
|
||||
changed.add(job)
|
||||
elif job is None:
|
||||
await rebalance.apply_event(session, event, now)
|
||||
elif event.get("kind") == "started":
|
||||
boot_at = _at(event.get("boot_at"))
|
||||
for job in (await session.execute(select(HostMaintenanceJob).where(
|
||||
@@ -441,8 +444,10 @@ async def handoffs(session, host_id: str, host_name: str, now: datetime,
|
||||
agent_version: str | None = None) -> list[dict]:
|
||||
"""The steps this host's agent should carry out now. A queued job for the
|
||||
host starts here, when its agent is known to be listening, with a check
|
||||
when the agent is new enough to hand one over."""
|
||||
when the agent is new enough to hand one over. A manager also carries out
|
||||
a run's rebalance."""
|
||||
from sqlalchemy import or_, select
|
||||
from . import rebalance
|
||||
from .models import HostMaintenanceJob
|
||||
jobs = (await session.execute(select(HostMaintenanceJob).where(
|
||||
HostMaintenanceJob.state.in_(ACTIVE),
|
||||
@@ -458,7 +463,7 @@ async def handoffs(session, host_id: str, host_name: str, now: datetime,
|
||||
step = handoff(job)
|
||||
if step is not None:
|
||||
out.append(step)
|
||||
return out
|
||||
return out + await rebalance.handoffs(session, host_id)
|
||||
|
||||
|
||||
async def _running(session, job) -> set:
|
||||
|
||||
@@ -15,7 +15,7 @@ from steward.mcp.tools.overview import finding
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from . import cadence, maintenance, senders
|
||||
from . import cadence, maintenance, rebalance, senders
|
||||
from .models import HostAgentRegistration, HostMaintenanceJob, HostMaintenancePlan, HostMaintenanceRun
|
||||
|
||||
# A registration this old that has never checked in is a failed install, not one in progress.
|
||||
@@ -143,6 +143,12 @@ async def list_maintenance_plans() -> dict:
|
||||
(`skipped`), and how it ended. `last_slot_at` is the last scheduled slot
|
||||
that fired, null until one has. get_maintenance_job shows one host's job.
|
||||
Plans are created and edited on the Maintenance page.
|
||||
|
||||
With `spread_after_run`, a run that drained swarm nodes ends by spreading
|
||||
the swarm's services back out, leaving `keep_in_place` where they are;
|
||||
`latest_run.spread` says what it moved and what it left and why (state:
|
||||
handed, done, failed, timed_out, or nothing to move), null when the run
|
||||
had none.
|
||||
"""
|
||||
from steward.models.ansible_inventory import AnsibleGroup
|
||||
now = now_utc()
|
||||
@@ -169,6 +175,8 @@ async def list_maintenance_plans() -> dict:
|
||||
"weekday": cadence.WEEKDAYS[plan.weekday], "at_time": plan.at_time,
|
||||
"time_zone": plan.time_zone, "window_minutes": plan.window_minutes,
|
||||
"reboot_policy": plan.reboot_policy, "enabled": plan.enabled,
|
||||
"spread_after_run": plan.spread,
|
||||
"keep_in_place": rebalance.keep_list(plan.keep_in_place),
|
||||
"next_slot_at": iso(nxt), "last_slot_at": iso(cadence.fired_slot(plan)),
|
||||
"latest_run": None,
|
||||
}
|
||||
@@ -181,12 +189,23 @@ async def list_maintenance_plans() -> dict:
|
||||
"skipped": [{"host": names.get(x["host_id"], x["host_id"]), "reason": x["reason"]}
|
||||
for x in json.loads(run.skipped or "[]")],
|
||||
"detail": run.detail,
|
||||
"spread": _spread_row(run.rebalance),
|
||||
"started_at": iso(run.started_at), "finished_at": iso(run.finished_at),
|
||||
}
|
||||
out.append(row)
|
||||
return {"plans": out, "total": len(out)}
|
||||
|
||||
|
||||
def _spread_row(rb: dict | None) -> dict | None:
|
||||
"""A run's rebalance as the tool shows it: no step ids or window ids."""
|
||||
if not rb:
|
||||
return None
|
||||
return {"state": rb.get("state"), "planned": rb.get("planned") or [],
|
||||
"moved": rb.get("moved") or [], "left": rb.get("left") or [],
|
||||
"detail": rb.get("detail"), "started_at": rb.get("at"),
|
||||
"finished_at": rb.get("finished_at")}
|
||||
|
||||
|
||||
async def get_maintenance_job(host: str = "", job: str = "", limit: int = 10) -> dict:
|
||||
"""A host's maintenance jobs, newest first (its running one first), or one
|
||||
job by id.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# plugins/host_agent/migrations/versions/host_agent_013_spread.py
|
||||
"""host_agent: a maintenance run spreads swarm services back out after its
|
||||
last host, and a plan can keep named services where they are
|
||||
|
||||
Revision ID: host_agent_013_spread
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "host_agent_013_spread"
|
||||
down_revision: Union[str, None] = "host_agent_012_ends_as"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("host_maintenance_plans", sa.Column("spread", sa.Boolean, nullable=False,
|
||||
server_default=sa.true()))
|
||||
op.add_column("host_maintenance_plans", sa.Column("keep_in_place", sa.Text, nullable=False,
|
||||
server_default=""))
|
||||
op.add_column("host_maintenance_runs", sa.Column("rebalance", sa.JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("host_maintenance_runs", "rebalance")
|
||||
op.drop_column("host_maintenance_plans", "keep_in_place")
|
||||
op.drop_column("host_maintenance_plans", "spread")
|
||||
@@ -134,7 +134,9 @@ class HostMaintenancePlan(Base):
|
||||
`at_time` in `time_zone`, its hosts are updated one at a time, each
|
||||
rebooting per `reboot_policy`. No host is started after `window_minutes`
|
||||
from the run's start. last_slot_at is the last scheduled slot that fired;
|
||||
Run now leaves it alone, so the schedule keeps its time."""
|
||||
Run now leaves it alone, so the schedule keeps its time. With `spread`, a
|
||||
run that drained swarm nodes ends by spreading services back out, leaving
|
||||
the services named in `keep_in_place` (comma-separated) where they are."""
|
||||
__tablename__ = "host_maintenance_plans"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
@@ -147,6 +149,8 @@ class HostMaintenancePlan(Base):
|
||||
window_minutes = Column(Integer, nullable=False, default=240)
|
||||
reboot_policy = Column(String(16), nullable=False, default="if_required")
|
||||
enabled = Column(Boolean, nullable=False, default=True)
|
||||
spread = Column(Boolean, nullable=False, default=True)
|
||||
keep_in_place = Column(Text, nullable=False, default="")
|
||||
last_slot_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
@@ -158,7 +162,10 @@ class HostMaintenanceRun(Base):
|
||||
host_ids is the order fixed at the start (JSON list); position is the host
|
||||
being worked on; skipped is [{"host_id", "reason"}] for hosts the run
|
||||
passed over. settle_since is when the current host's job finished and the
|
||||
run began waiting for the host to be back in service."""
|
||||
run began waiting for the host to be back in service. rebalance is the
|
||||
spreading of swarm services back out once the last host is back
|
||||
(rebalance.py): its state, the manager carrying it out, what it moved and
|
||||
what it left and why; None while not reached or not wanted."""
|
||||
__tablename__ = "host_maintenance_runs"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=_uuid)
|
||||
@@ -171,5 +178,6 @@ class HostMaintenanceRun(Base):
|
||||
settle_since = Column(DateTime(timezone=True), nullable=True)
|
||||
detail = Column(Text, nullable=True)
|
||||
manual = Column(Boolean, nullable=False, default=False)
|
||||
rebalance = Column(JSON, nullable=True)
|
||||
started_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
|
||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
# plugins/host_agent/rebalance.py
|
||||
"""A maintenance run spreads swarm services back out after its last host.
|
||||
|
||||
A run drains each swarm node in turn, and the swarm never moves a task back
|
||||
on its own, so every run used to end with the services on whichever node was
|
||||
drained first. Once the run's last host is back in service, Steward picks
|
||||
services to move from the fullest node to the emptiest and hands the list to
|
||||
a swarm manager's helper (maint.py `rebalance`), which force-updates each in
|
||||
turn: spec unchanged, no image pulled, and the swarm starts it again on its
|
||||
emptiest node.
|
||||
|
||||
A service moves only when the emptier node already holds the exact image the
|
||||
service is pinned to (its agent reports every image digest it holds), so a
|
||||
pruned image or a digest the registry has since deleted never strands one.
|
||||
A service the plan keeps in place, one below its replicas, and one a
|
||||
constraint places are left where they are, and the run says why.
|
||||
|
||||
The choice is model-free (`choose`); the database side is below it, driven by
|
||||
cadence.advance, the agent's posts (handoffs, results) and the minute tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import maintenance
|
||||
|
||||
# The first agent whose helper can rebalance, and that reports image digests.
|
||||
REBALANCE_AGENT = (1, 26, 0)
|
||||
# How many services one rebalance moves at most; the helper refuses more.
|
||||
SERVICES_MAX = 48
|
||||
# A node's list of images older than this isn't trusted (agents report it
|
||||
# every 10 minutes).
|
||||
DIGESTS_FRESH = timedelta(minutes=30)
|
||||
# The helper starts no move after 40 min and gives each 5 to settle.
|
||||
DEADLINE = timedelta(minutes=60)
|
||||
STATES = ("handed", "done", "failed", "timed_out", "nothing")
|
||||
WINDOW_NAME = "Maintenance: spreading swarm services back out"
|
||||
|
||||
|
||||
def keep_list(text: str | None) -> list[str]:
|
||||
"""A plan's keep_in_place as names, in the order written."""
|
||||
out = []
|
||||
for name in (text or "").replace("\n", ",").split(","):
|
||||
name = name.strip()
|
||||
if name and name not in out:
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def _repo(image: str) -> str:
|
||||
"""An image reference's repository, without tag or digest, in the short
|
||||
form Docker reports (no docker.io/ or library/)."""
|
||||
ref = image.split("@", 1)[0]
|
||||
slash = ref.rfind("/")
|
||||
colon = ref.rfind(":")
|
||||
if colon > slash:
|
||||
ref = ref[:colon]
|
||||
for prefix in ("docker.io/", "index.docker.io/"):
|
||||
if ref.startswith(prefix):
|
||||
ref = ref[len(prefix):]
|
||||
if ref.startswith("library/"):
|
||||
ref = ref[len("library/"):]
|
||||
return ref
|
||||
|
||||
|
||||
def pinned(image: str | None, digest: str | None) -> str | None:
|
||||
""""repo@sha256:…" for a service's image, or None when it isn't pinned."""
|
||||
if not image or not digest or not digest.startswith("sha256:"):
|
||||
return None
|
||||
return f"{_repo(image)}@{digest}"
|
||||
|
||||
|
||||
def held(digests) -> set:
|
||||
"""A node's reported digests, in the same form as pinned()."""
|
||||
return {f"{_repo(d)}@{d.split('@', 1)[1]}" for d in digests or []
|
||||
if isinstance(d, str) and "@sha256:" in d}
|
||||
|
||||
|
||||
def choose(services: list, nodes: dict, images: dict, keep=(),
|
||||
limit: int = SERVICES_MAX) -> tuple[list, list]:
|
||||
"""(services to move, [{"service", "reason"}] left where they are).
|
||||
|
||||
services: {"name", "mode", "desired", "running", "image" ("repo@sha256:…"
|
||||
or None), "placement": [{"node_id", "running"}]}; nodes: {node id: name}
|
||||
for the nodes to balance across (ready and active); images: {node id: set
|
||||
of "repo@sha256:…"}, a node missing when its list is unknown.
|
||||
|
||||
While the fullest node has two or more tasks than the emptiest, a service
|
||||
whose tasks all run on the fullest moves; one with several replicas there
|
||||
first, as one node holding them all defeats the replicas. A move counts
|
||||
as its share of the replicas going to the emptiest. What is reported left
|
||||
is what kept the nodes from evening out: the services that would have
|
||||
moved next but can't."""
|
||||
if len(nodes) < 2:
|
||||
return [], []
|
||||
counts = {n: 0 for n in nodes}
|
||||
for s in services:
|
||||
for p in s.get("placement") or []:
|
||||
if p.get("node_id") in counts:
|
||||
counts[p["node_id"]] += int(p.get("running") or 0)
|
||||
keep = set(keep)
|
||||
order = sorted(services, key=lambda s: (-int(s.get("running") or 0), s["name"]))
|
||||
moves: list = []
|
||||
left: dict = {}
|
||||
while len(moves) < limit:
|
||||
src = max(counts, key=lambda n: (counts[n], n))
|
||||
dst = min(counts, key=lambda n: (counts[n], n))
|
||||
gap = counts[src] - counts[dst]
|
||||
if gap < 2:
|
||||
break
|
||||
pick, blocked = None, {}
|
||||
for s in order:
|
||||
name, running = s["name"], int(s.get("running") or 0)
|
||||
on = {p.get("node_id"): int(p.get("running") or 0) for p in s.get("placement") or []
|
||||
if int(p.get("running") or 0) > 0}
|
||||
if name in moves or s.get("mode") != "replicated" or set(on) != {src}:
|
||||
continue
|
||||
share = max(1, running // len(nodes))
|
||||
if 2 * share > gap:
|
||||
continue # it would only tip the balance the other way
|
||||
if name in keep:
|
||||
blocked[name] = "kept in place by the plan"
|
||||
elif running < int(s.get("desired") or 0):
|
||||
blocked[name] = "below its replicas"
|
||||
elif not s.get("image"):
|
||||
blocked[name] = "its image isn't pinned to a digest"
|
||||
elif dst not in images:
|
||||
blocked[name] = f"no image list from {nodes[dst]} (its agent needs 1.26.0)"
|
||||
elif s["image"] not in images[dst]:
|
||||
blocked[name] = f"its image isn't on {nodes[dst]}"
|
||||
else:
|
||||
pick = s
|
||||
break
|
||||
if pick is None:
|
||||
left = blocked # what kept the nodes from evening out
|
||||
break
|
||||
share = max(1, int(pick.get("running") or 0) // len(nodes))
|
||||
moves.append(pick["name"])
|
||||
counts[src] -= share
|
||||
counts[dst] += share
|
||||
return moves, [{"service": n, "reason": r} for n, r in sorted(left.items())]
|
||||
|
||||
|
||||
def can_rebalance(agent_version) -> bool:
|
||||
try:
|
||||
return tuple(int(x) for x in str(agent_version).split(".")[:3]) >= REBALANCE_AGENT
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def step_id(run) -> str:
|
||||
return f"{run.id}-rebalance"
|
||||
|
||||
|
||||
def _set(run, **changes) -> None:
|
||||
"""Change the run's rebalance record (a new dict, so the JSON column
|
||||
sees the change)."""
|
||||
run.rebalance = {**(run.rebalance or {}), **changes}
|
||||
|
||||
|
||||
def handoff(run) -> dict | None:
|
||||
"""The step to hand the manager's agent, until its result arrives."""
|
||||
rb = run.rebalance or {}
|
||||
if rb.get("state") != "handed" or rb.get("seen"):
|
||||
return None
|
||||
return {"id": rb["step_id"], "action": "rebalance", "services": rb.get("planned") or []}
|
||||
|
||||
|
||||
def apply_result(run, event: dict, now: datetime) -> bool:
|
||||
"""A `maintenance` event for the run's rebalance; True when it changed."""
|
||||
rb = run.rebalance or {}
|
||||
if rb.get("state") != "handed" or event.get("id") != rb.get("step_id"):
|
||||
return False
|
||||
state = event.get("state")
|
||||
if state == "running":
|
||||
if rb.get("seen"):
|
||||
return False
|
||||
_set(run, seen=True)
|
||||
return True
|
||||
if state not in ("done", "failed"):
|
||||
return False
|
||||
moved = [str(m)[:64] for m in event.get("moved") or [] if isinstance(m, str)][:SERVICES_MAX]
|
||||
gone = [{"service": str(x.get("service"))[:64], "reason": str(x.get("reason"))[:160]}
|
||||
for x in event.get("left") or [] if isinstance(x, dict)][:SERVICES_MAX]
|
||||
ok = state == "done" and event.get("ok") is not False
|
||||
_set(run, state="done" if ok else "failed", moved=moved, left=(rb.get("left") or []) + gone,
|
||||
detail=str(event.get("detail") or "")[:maintenance.DETAIL_MAX] or None,
|
||||
finished_at=now.isoformat())
|
||||
return True
|
||||
|
||||
|
||||
def check_deadline(run, now: datetime) -> bool:
|
||||
rb = run.rebalance or {}
|
||||
if rb.get("state") != "handed" or now - datetime.fromisoformat(rb["at"]) <= DEADLINE:
|
||||
return False
|
||||
_set(run, state="timed_out", finished_at=now.isoformat(),
|
||||
detail=f"the manager didn't finish within {int(DEADLINE.total_seconds() // 60)} min")
|
||||
return True
|
||||
|
||||
|
||||
def outcome(run) -> tuple[str, str | None]:
|
||||
"""How a run whose hosts are all done ends: ("done" | "failed", detail)."""
|
||||
rb = run.rebalance or {}
|
||||
state = rb.get("state")
|
||||
left = rb.get("left") or []
|
||||
tail = f"; {len(left)} left where they are" if left else ""
|
||||
if state == "done":
|
||||
n = len(rb.get("moved") or [])
|
||||
return "done", f"spread {n} swarm service{'s' if n != 1 else ''} back out{tail}"
|
||||
if state in ("failed", "timed_out"):
|
||||
return "failed", f"spreading swarm services back out: {rb.get('detail') or state}"
|
||||
if state == "nothing" and rb.get("detail"):
|
||||
return "done", f"swarm services not spread back out: {rb['detail']}"
|
||||
if state == "nothing" and left:
|
||||
return "done", f"no swarm service could be spread back out{tail}"
|
||||
return "done", None
|
||||
|
||||
|
||||
def label(run) -> tuple[str, bool, bool] | None:
|
||||
"""(text, bad, busy) for the run's swarm row on the page, or None when
|
||||
the run has no rebalance."""
|
||||
rb = run.rebalance or {}
|
||||
state = rb.get("state")
|
||||
if state is None:
|
||||
return None
|
||||
if state == "handed":
|
||||
n = len(rb.get("planned") or [])
|
||||
return f"Spreading {n} service{'s' if n != 1 else ''} back out", False, True
|
||||
text = outcome(run)[1] or "Nothing to spread back out"
|
||||
return text[:1].upper() + text[1:], state in ("failed", "timed_out"), False
|
||||
|
||||
|
||||
# ── applied to the database ──────────────────────────────────────────────────
|
||||
|
||||
async def _freshest(session, model, key: str) -> list:
|
||||
"""The newest row per `key` (several managers may report the swarm)."""
|
||||
from sqlalchemy import select
|
||||
rows: dict = {}
|
||||
for r in (await session.execute(select(model))).scalars():
|
||||
k = getattr(r, key)
|
||||
if k not in rows or r.updated_at > rows[k].updated_at:
|
||||
rows[k] = r
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
async def _swarm_view(session, now: datetime) -> tuple[list, dict, dict] | None:
|
||||
"""(services, nodes, images) as choose() takes them, from what the Docker
|
||||
plugin last stored; None without it."""
|
||||
from sqlalchemy import select
|
||||
from steward.models.hosts import Host
|
||||
models = maintenance.docker_models()
|
||||
if models is None:
|
||||
return None
|
||||
services = [{"name": s.service_name, "mode": s.mode, "desired": s.desired,
|
||||
"running": s.running, "image": pinned(s.image, s.image_digest),
|
||||
"placement": json.loads(s.placement_json or "[]")}
|
||||
for s in await _freshest(session, models.DockerSwarmService, "service_name")]
|
||||
nodes = {n.node_id: n.hostname or n.node_id
|
||||
for n in await _freshest(session, models.DockerSwarmNode, "node_id")
|
||||
if n.status == "ready" and n.availability == "active"}
|
||||
hosts = dict((await session.execute(select(Host.name, Host.id))).all())
|
||||
by_name = {name.lower(): hid for name, hid in hosts.items()}
|
||||
images = {}
|
||||
for node_id, name in nodes.items():
|
||||
host_id = by_name.get(name.lower())
|
||||
disk = await session.get(models.DockerDiskUsage, host_id) if host_id else None
|
||||
if disk is not None and now - disk.updated_at <= DIGESTS_FRESH:
|
||||
got = held(json.loads(disk.digests_json or "[]"))
|
||||
if got:
|
||||
images[node_id] = got
|
||||
return services, nodes, images
|
||||
|
||||
|
||||
async def _manager(session, jobs: list) -> tuple[str | None, str | None]:
|
||||
"""(host id, None) of a swarm manager whose helper can rebalance, the one
|
||||
that drained the run's nodes first; (None, why) when there is none."""
|
||||
from sqlalchemy import select
|
||||
from .models import HostAgentRegistration
|
||||
_, managers, _ = await maintenance._swarm(session)
|
||||
regs = {r.host_id: r for r in (await session.execute(select(HostAgentRegistration).where(
|
||||
HostAgentRegistration.host_id.in_(list(managers)),
|
||||
HostAgentRegistration.maint_helper.is_(True)))).scalars()} if managers else {}
|
||||
drained_by = [j.drain_host_id for j in jobs if j.drain_host_id]
|
||||
for host_id in drained_by + sorted(regs):
|
||||
reg = regs.get(host_id)
|
||||
if reg is not None and can_rebalance(reg.agent_version):
|
||||
return host_id, None
|
||||
if regs:
|
||||
return None, "no swarm manager's agent can do it yet: run Update agent on a manager"
|
||||
return None, "no swarm manager has the maintenance helper"
|
||||
|
||||
|
||||
async def begin(session, run, plan, now: datetime) -> bool:
|
||||
"""The run's last host is back in service: hand a manager the services to
|
||||
move, or record why there are none. True while a rebalance is handed out."""
|
||||
from sqlalchemy import select
|
||||
from .models import HostMaintenanceJob
|
||||
if not plan.spread or run.rebalance is not None:
|
||||
return False
|
||||
jobs = (await session.execute(select(HostMaintenanceJob).where(
|
||||
HostMaintenanceJob.run_id == run.id))).scalars().all()
|
||||
if not any(maintenance.drains(j) for j in jobs):
|
||||
return False # nothing was drained: nothing to put back
|
||||
view = await _swarm_view(session, now)
|
||||
if view is None:
|
||||
return False
|
||||
services, nodes, images = view
|
||||
moves, left = choose(services, nodes, images, keep_list(plan.keep_in_place))
|
||||
if not moves:
|
||||
run.rebalance = {"state": "nothing", "left": left, "at": now.isoformat()}
|
||||
return False
|
||||
host_id, why = await _manager(session, jobs)
|
||||
if host_id is None:
|
||||
run.rebalance = {"state": "nothing", "left": left, "detail": why, "at": now.isoformat()}
|
||||
return False
|
||||
run.rebalance = {"state": "handed", "step_id": step_id(run), "host_id": host_id,
|
||||
"at": now.isoformat(), "planned": moves, "left": left, "seen": False,
|
||||
"window_id": await _window(session, plan, now)}
|
||||
return True
|
||||
|
||||
|
||||
async def _window(session, plan, now: datetime) -> str | None:
|
||||
"""A window over the Docker figures while services restart, so their
|
||||
restarts raise no alert; ended when the rebalance does."""
|
||||
from steward.models.alerts import MaintenanceWindow
|
||||
if plan.created_by is None:
|
||||
return None
|
||||
w = MaintenanceWindow(name=WINDOW_NAME, start_at=now, end_at=now + DEADLINE,
|
||||
scope_source="docker", scope_resource=None, created_by=plan.created_by)
|
||||
session.add(w)
|
||||
await session.flush()
|
||||
return w.id
|
||||
|
||||
|
||||
async def finish(session, run, now: datetime) -> None:
|
||||
"""The rebalance ended: its window ends, and it goes on the timeline."""
|
||||
from steward.models.alerts import MaintenanceWindow
|
||||
from . import timeline
|
||||
rb = run.rebalance or {}
|
||||
if rb.get("window_id"):
|
||||
w = await session.get(MaintenanceWindow, rb["window_id"])
|
||||
if w is not None:
|
||||
w.end_at = max(w.start_at, now)
|
||||
if rb.get("state") in ("done", "failed", "timed_out"):
|
||||
await timeline.write(session, f"maintenance run {run.id}", timeline.run_entry(session, run))
|
||||
|
||||
|
||||
async def running_runs(session) -> list:
|
||||
from sqlalchemy import select
|
||||
from .models import HostMaintenanceRun
|
||||
return (await session.execute(select(HostMaintenanceRun).where(
|
||||
HostMaintenanceRun.state == "running"))).scalars().all()
|
||||
|
||||
|
||||
async def handoffs(session, host_id: str) -> list[dict]:
|
||||
"""The rebalance steps this host's agent should carry out now."""
|
||||
out = []
|
||||
for run in await running_runs(session):
|
||||
if (run.rebalance or {}).get("host_id") == host_id:
|
||||
step = handoff(run)
|
||||
if step is not None:
|
||||
out.append(step)
|
||||
return out
|
||||
|
||||
|
||||
async def apply_event(session, event: dict, now: datetime) -> bool:
|
||||
"""Apply a `maintenance` event to the running run whose rebalance it is."""
|
||||
for run in await running_runs(session):
|
||||
if apply_result(run, event, now):
|
||||
if (run.rebalance or {}).get("state") != "handed":
|
||||
await finish(session, run, now)
|
||||
return True
|
||||
return False
|
||||
@@ -1442,8 +1442,10 @@ async def update_via_ansible():
|
||||
# ── Maintenance cadence (#5768) ───────────────────────────────────────────────
|
||||
|
||||
def _run_rows(run, jobs: dict, names: dict) -> list[dict]:
|
||||
"""A run's hosts in order, each with where it stands, then those passed over."""
|
||||
"""A run's hosts in order, each with where it stands, then those passed
|
||||
over, then the spreading of its swarm services back out."""
|
||||
import json as _json
|
||||
from . import rebalance
|
||||
rows = []
|
||||
for i, host_id in enumerate(_json.loads(run.host_ids)):
|
||||
job = jobs.get((run.id, host_id))
|
||||
@@ -1462,6 +1464,13 @@ def _run_rows(run, jobs: dict, names: dict) -> list[dict]:
|
||||
for s in _json.loads(run.skipped):
|
||||
rows.append({"host_id": s["host_id"], "name": names.get(s["host_id"], s["host_id"]),
|
||||
"text": f"Skipped: {s['reason']}", "bad": False, "busy": False, "detail": None})
|
||||
swarm = rebalance.label(run)
|
||||
if swarm is not None:
|
||||
rb = run.rebalance or {}
|
||||
said = [f"moved: {', '.join(rb['moved'])}"] if rb.get("moved") else []
|
||||
said += [f"{x['service']}: {x['reason']}" for x in rb.get("left") or []]
|
||||
rows.append({"host_id": None, "name": "Swarm services", "text": swarm[0], "bad": swarm[1],
|
||||
"busy": swarm[2], "detail": "; ".join(said) or None})
|
||||
return rows
|
||||
|
||||
|
||||
@@ -1532,6 +1541,7 @@ async def maintenance_plan_create():
|
||||
return _error(400, "invalid", "weekday, time (HH:MM) and window must be numbers")
|
||||
policy = form.get("reboot_policy") or "if_required"
|
||||
tz = (form.get("time_zone") or "UTC").strip()
|
||||
spread, keep = _spread_form(form)
|
||||
if not name or not 0 <= weekday <= 6 or not 30 <= window <= 24 * 60 \
|
||||
or policy not in maintenance.REBOOT_POLICIES:
|
||||
return _error(400, "invalid", "name, a weekday, a window of 30 min to 24 h and a reboot policy are needed")
|
||||
@@ -1543,6 +1553,7 @@ async def maintenance_plan_create():
|
||||
plan = HostMaintenancePlan(name=name, group_id=form["group_id"], weekday=weekday,
|
||||
at_time=f"{cadence.parse_at(at_time):%H:%M}", time_zone=tz,
|
||||
window_minutes=window, reboot_policy=policy, enabled=True,
|
||||
spread=spread, keep_in_place=keep,
|
||||
created_by=session.get("user_id"),
|
||||
# the first slot is the next one, not one already past
|
||||
last_slot_at=datetime.now(timezone.utc))
|
||||
@@ -1553,6 +1564,13 @@ async def maintenance_plan_create():
|
||||
return redirect(url_for("host_agent.maintenance_page"))
|
||||
|
||||
|
||||
def _spread_form(form) -> tuple[bool, str]:
|
||||
"""(spread, keep_in_place) from a plan form: the box, and the services
|
||||
named to stay where they are, tidied to one comma-separated list."""
|
||||
from .rebalance import keep_list
|
||||
return form.get("spread") == "on", ", ".join(keep_list(form.get("keep_in_place")))[:2000]
|
||||
|
||||
|
||||
async def _plan_action(plan_id: str, act):
|
||||
from .models import HostMaintenancePlan
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
@@ -1578,6 +1596,22 @@ async def maintenance_plan_toggle(plan_id: str):
|
||||
return resp
|
||||
|
||||
|
||||
@host_agent_bp.post("/maintenance/plans/<plan_id>/spread")
|
||||
@require_role(UserRole.admin)
|
||||
async def maintenance_plan_spread(plan_id: str):
|
||||
"""Whether the plan's runs end by spreading swarm services back out, and
|
||||
which services stay where they are."""
|
||||
form = await request.form
|
||||
spread, keep = _spread_form(form)
|
||||
|
||||
async def act(db, plan):
|
||||
plan.spread, plan.keep_in_place = spread, keep
|
||||
resp = await _plan_action(plan_id, act)
|
||||
await audit_request("host_agent.maintenance_plan_spread", "maintenance_plan", plan_id,
|
||||
{"spread": spread, "keep_in_place": keep})
|
||||
return resp
|
||||
|
||||
|
||||
@host_agent_bp.post("/maintenance/plans/<plan_id>/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def maintenance_plan_delete(plan_id: str):
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div style="display:flex;flex-direction:column;gap:0.15rem;font-size:0.82rem;">
|
||||
{% for r in rows %}
|
||||
<div style="display:flex;gap:0.6rem;align-items:baseline;" {% if r.detail %}title="{{ r.detail }}"{% endif %}>
|
||||
<a href="/hosts/{{ r.host_id }}" style="min-width:10rem;">{{ r.name }}</a>
|
||||
{% if r.host_id %}<a href="/hosts/{{ r.host_id }}" style="min-width:10rem;">{{ r.name }}</a>{% else %}<span style="min-width:10rem;">{{ r.name }}</span>{% endif %}
|
||||
<span class="{{ 'tone-warn' if r.bad else '' }}" style="{{ '' if r.bad or r.busy else 'color:var(--text-muted);' }}">{{ r.text }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -24,7 +24,9 @@
|
||||
a reboot, or before the update when it upgrades Docker, given 5 minutes for its tasks to move,
|
||||
and made active again after. Containers running outside the swarm must be running again
|
||||
within 10 minutes. The next host starts once the last is back in service. A failure stops the
|
||||
run, and the host's <em>Maintenance failed</em> figure can drive an alert rule.
|
||||
run, and the host's <em>Maintenance failed</em> figure can drive an alert rule. Once every host
|
||||
is back, a run that drained swarm nodes spreads the services back out: only those whose image
|
||||
the emptier node already holds, never one the plan keeps in place.
|
||||
</p>
|
||||
|
||||
{% if live %}
|
||||
@@ -68,8 +70,26 @@
|
||||
Group <a href="/hosts/?group={{ p.group|urlencode }}">{{ p.group }}</a> ({{ p.hosts }} host{{ 's' if p.hosts != 1 }})
|
||||
· {{ p.when }}
|
||||
· reboot {{ {'if_required': 'only when required', 'always': 'always', 'never': 'never'}[p.plan.reboot_policy] }}
|
||||
· {% if p.plan.spread %}spreads swarm services back out{% if p.plan.keep_in_place %}, keeping {{ p.plan.keep_in_place }} in place{% endif %}{% else %}leaves swarm services where the run put them{% endif %}
|
||||
{% if p.next %}· next {{ p.next|when }}{% endif %}
|
||||
</div>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<details style="margin-bottom:0.5rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">After the run</summary>
|
||||
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/spread"
|
||||
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;margin:0.4rem 0 0 1rem;">{{ csrf_field() }}
|
||||
<label style="display:flex;gap:0.4rem;align-items:center;font-size:0.84rem;">
|
||||
<input type="checkbox" name="spread" {% if p.plan.spread %}checked{% endif %}> Spread swarm services back out
|
||||
</label>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Keep in place</label>
|
||||
<input type="text" name="keep_in_place" value="{{ p.plan.keep_in_place }}" maxlength="2000"
|
||||
placeholder="web_proxy, metrics_app" style="width:22rem;" title="Swarm service names, comma-separated">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% for r in p.runs %}
|
||||
<details {% if loop.first %}open{% endif %} style="margin-top:0.3rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;">
|
||||
@@ -129,6 +149,14 @@
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
<label style="display:flex;gap:0.4rem;align-items:center;font-size:0.84rem;margin-bottom:0.45rem;">
|
||||
<input type="checkbox" name="spread" checked> Spread swarm services back out after
|
||||
</label>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Keep in place</label>
|
||||
<input type="text" name="keep_in_place" maxlength="2000" placeholder="web_proxy, metrics_app"
|
||||
style="width:14rem;" title="Swarm service names, comma-separated">
|
||||
</div>
|
||||
<button type="submit" class="btn">Add plan</button>
|
||||
</form>
|
||||
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.6rem 0 0;">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# plugins/host_agent/timeline.py
|
||||
"""What the host agent puts on the timeline: each maintenance job once it
|
||||
ends, and each stretch a host was away (a reboot, a crash, lost contact, the
|
||||
ends, the swarm services a maintenance run spread back out after its last
|
||||
host, and each stretch a host was away (a reboot, a crash, lost contact, the
|
||||
agent restarting). One entry per job and per downtime row, kept up to date
|
||||
as the row changes: a downtime that ends gets its length, a failed job
|
||||
whose host came back late reads as back.
|
||||
@@ -8,6 +9,7 @@ whose host came back late reads as back.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -23,6 +25,8 @@ TYPES = (
|
||||
"A host's agent updated it, and rebooted it when the update needed one."),
|
||||
("maintenance.failed", "Maintenance failed", "automation", "host_os", True,
|
||||
"A maintenance job failed or timed out."),
|
||||
("maintenance.rebalanced", "Swarm services spread back out", "automation", "service", True,
|
||||
"After a maintenance run, its swarm services were moved back onto the nodes it had drained."),
|
||||
("host.rebooted", "Host rebooted", "observed", "host_os", True,
|
||||
"A host went down and came back, announced or not (a crash, a power cut)."),
|
||||
("host.lost_contact", "Agent lost contact", "observed", "network", True,
|
||||
@@ -35,8 +39,9 @@ TYPES = (
|
||||
def register() -> None:
|
||||
for t in TYPES:
|
||||
catalog.register_type(*t)
|
||||
from .models import HostDowntime, HostMaintenanceJob
|
||||
from .models import HostDowntime, HostMaintenanceJob, HostMaintenanceRun
|
||||
catalog.register_ref("maintenance_job", HostMaintenanceJob)
|
||||
catalog.register_ref("maintenance_run", HostMaintenanceRun)
|
||||
catalog.register_ref("host_downtime", HostDowntime)
|
||||
|
||||
|
||||
@@ -62,6 +67,26 @@ async def job_entry(session, job, host_name: str | None = None):
|
||||
actor=await username(session, job.created_by) or "Steward")
|
||||
|
||||
|
||||
async def run_entry(session, run):
|
||||
"""The entry for a run's spreading of swarm services back out, once it ended."""
|
||||
from . import rebalance
|
||||
from .models import HostMaintenancePlan
|
||||
rb = run.rebalance or {}
|
||||
if rb.get("state") not in ("done", "failed", "timed_out"):
|
||||
return None
|
||||
plan = await session.get(HostMaintenancePlan, run.plan_id)
|
||||
summary = rebalance.outcome(run)[1] or "swarm services spread back out"
|
||||
return await record_or_update(
|
||||
session, ("maintenance_run", run.id), type="maintenance.rebalanced",
|
||||
at=datetime.fromisoformat(rb["at"]),
|
||||
until=datetime.fromisoformat(rb["finished_at"]) if rb.get("finished_at") else None,
|
||||
summary=f"{plan.name if plan else 'Maintenance'}: {summary}",
|
||||
hosts=[], subject=None,
|
||||
detail={"state": rb["state"], "moved": rb.get("moved") or [], "left": rb.get("left") or [],
|
||||
"note": rb.get("detail"), "url": "/plugins/host_agent/maintenance"},
|
||||
actor=await username(session, plan.created_by if plan else None) or "Steward")
|
||||
|
||||
|
||||
_DOWNTIME_TYPES = {"reboot": "host.rebooted", "unclean": "host.rebooted",
|
||||
"lost_contact": "host.lost_contact", "agent_stop": "host.agent_restarted"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user