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>
174 lines
9.3 KiB
HTML
174 lines
9.3 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_macros.html" import crumbs, empty_state, page_header %}
|
|
{% block title %}Maintenance — Steward{% endblock %}
|
|
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Maintenance", "")]) }}{% endblock %}
|
|
{% block content %}
|
|
{# A group's updates and reboots on a weekly cadence, one host at a time
|
|
(#5768). What needs attention first: a run going now, a run that failed;
|
|
then the plans with their recent runs. #}
|
|
{% macro run_hosts(rows) -%}
|
|
<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 %}>
|
|
{% 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 %}
|
|
</div>
|
|
{%- endmacro %}
|
|
|
|
{{ page_header("Maintenance") }}
|
|
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
|
|
Each plan updates an inventory group's hosts once a week, one host at a time. A host reboots
|
|
only when its update needs it, unless the plan says otherwise. A swarm node is drained before
|
|
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. 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 %}
|
|
{% for l in live %}
|
|
<div class="card">
|
|
<h3 style="margin-bottom:0.5rem;">Running: {{ l.plan }} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· started {{ l.run.started_at|ago }}{% if l.run.manual %} · run now{% endif %}</span></h3>
|
|
{{ run_hosts(l.rows) }}
|
|
</div>
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
{% for f in failed %}
|
|
<div class="card" style="border-color:color-mix(in srgb,var(--yellow) 40%,var(--border));">
|
|
<span class="tone-warn">Failed: {{ f.plan }}</span>
|
|
<span style="font-size:0.82rem;color:var(--text-muted);">· {{ f.run.finished_at|ago }} · {{ f.run.detail or "no detail" }}</span>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
{% if plans %}
|
|
{% for p in plans %}
|
|
<div class="card">
|
|
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
|
|
<h3 style="margin-bottom:0.2rem;">{{ p.plan.name }}{% if not p.plan.enabled %} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· paused</span>{% endif %}</h3>
|
|
{% if session.user_role == 'admin' %}
|
|
<div style="display:flex;gap:0.4rem;">
|
|
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/run" style="margin:0;"
|
|
data-confirm="Run {{ p.plan.name }} now? Its {{ p.hosts }} host{{ 's' if p.hosts != 1 }} are updated one at a time; the schedule keeps its time.">{{ csrf_field() }}
|
|
<button type="submit" class="btn btn-sm">Run now</button>
|
|
</form>
|
|
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/toggle" style="margin:0;">{{ csrf_field() }}
|
|
<button type="submit" class="btn btn-sm btn-ghost">{{ 'Pause' if p.plan.enabled else 'Resume' }}</button>
|
|
</form>
|
|
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/delete" style="margin:0;"
|
|
data-confirm="Delete {{ p.plan.name }} and its run history? Hosts' own job history stays.">{{ csrf_field() }}
|
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.6rem;">
|
|
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;">
|
|
<span class="{{ 'tone-warn' if r.run.state == 'failed' else '' }}">{{ {'running': 'Running', 'done': 'Done', 'failed': 'Failed', 'stopped': 'Stopped'}[r.run.state] }}</span>
|
|
<span style="color:var(--text-muted);">· {{ r.run.started_at|when }}{% if r.run.manual %} · run now{% endif %}{% if r.run.detail %} · {{ r.run.detail|truncate(100) }}{% endif %}</span>
|
|
</summary>
|
|
<div style="margin:0.3rem 0 0.4rem 1rem;">{{ run_hosts(r.rows) }}</div>
|
|
</details>
|
|
{% else %}
|
|
<p style="font-size:0.82rem;color:var(--text-dim);margin:0;">No runs yet.</p>
|
|
{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="card">{{ empty_state("No maintenance plans yet." ~ (" Add one below." if session.user_role == 'admin' else "")) }}</div>
|
|
{% endif %}
|
|
|
|
{% if session.user_role == 'admin' %}
|
|
<div class="card">
|
|
<h3 style="margin-bottom:0.6rem;">Add a plan</h3>
|
|
{% if groups %}
|
|
<form method="post" action="/plugins/host_agent/maintenance/plans"
|
|
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Name</label>
|
|
<input type="text" name="name" required maxlength="128" placeholder="Docker hosts, weekly" style="width:13rem;">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Group</label>
|
|
<select name="group_id" required>
|
|
{% for g in groups %}<option value="{{ g.id }}">{{ g.name }}</option>{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Day</label>
|
|
<select name="weekday">
|
|
{% for d in weekdays %}<option value="{{ loop.index0 }}" {% if loop.index0 == 6 %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Time</label>
|
|
<input type="time" name="at_time" value="03:00" required style="width:7rem;">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Time zone</label>
|
|
<input type="text" name="time_zone" value="{{ default_zone }}" required style="width:11rem;">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Window (min)</label>
|
|
<input type="number" name="window_minutes" value="240" min="30" max="1440" style="width:6rem;">
|
|
</div>
|
|
<div class="form-group" style="margin-bottom:0;">
|
|
<label>Reboot</label>
|
|
<select name="reboot_policy">
|
|
<option value="if_required" selected>Only when required</option>
|
|
<option value="always">Always</option>
|
|
<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;">
|
|
Hosts whose agent has no maintenance helper are skipped and listed; run Update agent on them.
|
|
No host is started once the window has passed.
|
|
</p>
|
|
{% else %}
|
|
<p style="font-size:0.85rem;color:var(--text-dim);margin:0;">
|
|
Plans work on inventory groups. Add one under <a href="/ansible/inventory/">Ansible → Inventory</a>.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|