feat(docker): swarm-aware container views — collapse replicas, show host, surface agent-less tasks
The container list and dashboard widget listed each swarm task as its own cryptic row (svc.1.<taskid>) and could only show tasks on nodes that run a Steward agent. Make both views service-centric and cluster-complete. - swarm_view.build_swarm_services(): model-free builder that merges real container rows (collected local-per-node, so host_id = the node a task runs on) with the managers' placement (DockerSwarmService.placement_json). Where placement counts exceed the local rows on a node — i.e. nodes with no agent — it synthesizes "ghost" replicas so the replica list matches the cluster. Non-swarm containers pass through grouped by host. - /rows + rows.html: a Swarm-services panel collapses each service to one block, every replica a host chip (status + cpu); ghosts render dashed "N · no agent". Non-swarm/compose containers keep the per-host table below. New Services stat. - /widget + widget.html: same service collapse with host chips; standalone containers stay grouped by host. - Unit tests for the builder (real+ghost merge, full coverage, partial agent-less, synthesized service, down state). No migration — node_id, placement_json and swarm-node hostnames are already stored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
+50
-20
@@ -11,6 +11,7 @@ from steward.models.users import UserRole
|
||||
from steward.models.hosts import Host
|
||||
from steward.core.time_range import parse_range, DEFAULT_RANGE
|
||||
from .dedup import dedup_by_container_id
|
||||
from .swarm_view import build_swarm_services
|
||||
from .models import (
|
||||
DockerContainer, DockerDiskUsage, DockerEvent, DockerImage, DockerMetric,
|
||||
DockerSwarmNode, DockerSwarmService,
|
||||
@@ -138,7 +139,8 @@ async def _host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
||||
@docker_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
"""HTMX fragment: containers grouped by host, with resource sparklines."""
|
||||
"""HTMX fragment: swarm services (collapsed, cluster-complete) on top, then
|
||||
non-swarm containers grouped by host with resource sparklines."""
|
||||
since, range_key = parse_range(request.args.get("range"))
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
@@ -148,12 +150,24 @@ async def rows():
|
||||
DockerContainer.name,
|
||||
)
|
||||
)).scalars())
|
||||
hosts = await _host_map(db, {c.host_id for c in containers})
|
||||
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||
hosts = await _host_map(
|
||||
db,
|
||||
{c.host_id for c in containers}
|
||||
| {n.host_id for n in nodes} | {s.host_id for s in services},
|
||||
)
|
||||
|
||||
# Group by host so each container is clearly attributed to the box it
|
||||
# runs on (names are only unique within a host now).
|
||||
# Swarm tasks collapse into per-service rows (each replica labelled with
|
||||
# its host, plus "ghost" replicas for tasks on agent-less nodes, drawn
|
||||
# from the managers' placement). Non-swarm containers keep the host view.
|
||||
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||
host_name = {h.id: h.name for h in hosts.values()}
|
||||
swarm_view = build_swarm_services(containers, services, node_hostname, host_name)
|
||||
|
||||
standalone = [c for c in containers if not c.service_name]
|
||||
groups: dict[str, dict] = {}
|
||||
for c in containers:
|
||||
for c in standalone:
|
||||
g = groups.get(c.host_id)
|
||||
if g is None:
|
||||
host = hosts.get(c.host_id)
|
||||
@@ -176,15 +190,15 @@ async def rows():
|
||||
else:
|
||||
g["stopped"] += 1
|
||||
|
||||
# Sub-group each host's containers by compose project (or swarm service),
|
||||
# preserving the running-first ordering. Containers with neither label fall
|
||||
# into an unlabelled bucket rendered flat, so plain hosts look unchanged.
|
||||
# Sub-group each host's containers by compose project, preserving the
|
||||
# running-first ordering. Containers with no project fall into an unlabelled
|
||||
# bucket rendered flat, so plain hosts look unchanged.
|
||||
for g in groups.values():
|
||||
subs: dict[str, dict] = {}
|
||||
order: list[str] = []
|
||||
for item in g["containers"]:
|
||||
c = item["container"]
|
||||
label = c.compose_project or c.service_name or None
|
||||
label = c.compose_project or None
|
||||
key = label or ""
|
||||
if key not in subs:
|
||||
subs[key] = {"label": label, "containers": []}
|
||||
@@ -194,14 +208,16 @@ async def rows():
|
||||
g["grouped"] = any(s["label"] for s in g["subgroups"])
|
||||
|
||||
host_groups = sorted(groups.values(), key=lambda g: g["host_name"].lower())
|
||||
running = sum(g["running"] for g in host_groups)
|
||||
total = len(containers)
|
||||
swarm_services = swarm_view["services"]
|
||||
standalone_running = sum(g["running"] for g in host_groups)
|
||||
ghost_total = sum(r["count"] for s in swarm_services for r in s["replicas"] if r["ghost"])
|
||||
return await render_template(
|
||||
"docker/rows.html",
|
||||
host_groups=host_groups,
|
||||
running=running,
|
||||
stopped=total - running,
|
||||
total=total,
|
||||
swarm_services=swarm_services,
|
||||
running=standalone_running + sum(s["running"] for s in swarm_services),
|
||||
stopped=len(standalone) - standalone_running,
|
||||
total=len(containers) + ghost_total,
|
||||
range_key=range_key,
|
||||
)
|
||||
|
||||
@@ -221,6 +237,8 @@ async def widget():
|
||||
DockerContainer.name,
|
||||
)
|
||||
)).scalars()))
|
||||
services = list((await db.execute(select(DockerSwarmService))).scalars())
|
||||
nodes = list((await db.execute(select(DockerSwarmNode))).scalars())
|
||||
# Recent failures are more actionable than a static "stopped" tally. Count
|
||||
# DISTINCT container names so the two managers' duplicate die/oom events
|
||||
# don't double it.
|
||||
@@ -228,17 +246,28 @@ async def widget():
|
||||
select(func.count(func.distinct(DockerEvent.container_name)))
|
||||
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since_24h)
|
||||
)).scalar() or 0
|
||||
hosts = await _host_map(db, {c.host_id for c in all_containers})
|
||||
hosts = await _host_map(
|
||||
db,
|
||||
{c.host_id for c in all_containers}
|
||||
| {n.host_id for n in nodes} | {s.host_id for s in services},
|
||||
)
|
||||
|
||||
running = [c for c in all_containers if c.status == "running"]
|
||||
display = all_containers if show_stopped else running
|
||||
# Swarm tasks collapse into per-service rows (each replica tagged with its
|
||||
# host); non-swarm containers stay grouped by host.
|
||||
node_hostname = {n.node_id: n.hostname for n in nodes if n.hostname}
|
||||
host_name = {h.id: h.name for h in hosts.values()}
|
||||
swarm_services = build_swarm_services(all_containers, services, node_hostname, host_name)["services"]
|
||||
|
||||
standalone = [c for c in all_containers if not c.service_name]
|
||||
running = [c for c in standalone if c.status == "running"]
|
||||
display = standalone if show_stopped else running
|
||||
|
||||
# Group for display so multi-host fleets read clearly; single-host stays flat.
|
||||
groups: dict[str, dict] = {}
|
||||
for c in display:
|
||||
g = groups.setdefault(c.host_id, {
|
||||
"host_id": c.host_id,
|
||||
"host_name": hosts[c.host_id].name if c.host_id in hosts else c.host_id,
|
||||
"host_name": host_name.get(c.host_id, c.host_id),
|
||||
"containers": [],
|
||||
})
|
||||
g["containers"].append(c)
|
||||
@@ -248,9 +277,10 @@ async def widget():
|
||||
"docker/widget.html",
|
||||
host_groups=host_groups,
|
||||
multi_host=len(host_groups) > 1,
|
||||
running_count=len(running),
|
||||
swarm_services=swarm_services,
|
||||
running_count=len(running) + sum(s["running"] for s in swarm_services),
|
||||
failed_24h=failed_24h,
|
||||
total_count=len(all_containers),
|
||||
total_count=len(all_containers) + len(swarm_services),
|
||||
show_stopped=show_stopped,
|
||||
widget_id=widget_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Model-free builder for the swarm-aware container view.
|
||||
|
||||
Merges two sources into one service-grouped, cluster-complete picture:
|
||||
• docker_containers — real container rows, collected LOCAL-per-node, so each
|
||||
carries the host (= node) it runs on plus its swarm service/node labels.
|
||||
• DockerSwarmService.placement_json — the managers' cluster-wide task→node
|
||||
counts, which include tasks on nodes that run no Steward agent (and so have
|
||||
no container row of their own).
|
||||
|
||||
For every swarm service we list the real replicas we have detail for, then add
|
||||
"ghost" replicas for the remaining placement count on each node (agent-less
|
||||
nodes). Non-swarm containers pass through grouped by host, unchanged.
|
||||
|
||||
Kept model-free (no ORM imports) so it's unit-testable and safe to import in
|
||||
tests without the plugin-loader "table already defined" gotcha.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _service_state(running: int, desired: int) -> str:
|
||||
if desired > 0 and running >= desired:
|
||||
return "healthy"
|
||||
if running > 0:
|
||||
return "degraded"
|
||||
return "down"
|
||||
|
||||
|
||||
def _placement(service) -> list[dict]:
|
||||
raw = getattr(service, "placement_json", None) or "[]"
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return [p for p in data if isinstance(p, dict)]
|
||||
|
||||
|
||||
def build_swarm_services(containers, services, node_hostname: dict, host_name: dict) -> dict:
|
||||
"""Return {"services": [...], "standalone": [...]}.
|
||||
|
||||
services[i] = {name, mode, desired, running, state, replicas:[...]}
|
||||
real replica = {ghost:False, host, host_id, name, status, cpu_pct, mem_pct,
|
||||
health, restart_count}
|
||||
ghost replica = {ghost:True, host, count} # placement with no local row
|
||||
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
|
||||
"""
|
||||
containers = list(containers)
|
||||
swarm_cs = [c for c in containers if getattr(c, "service_name", None)]
|
||||
standalone_cs = [c for c in containers if not getattr(c, "service_name", None)]
|
||||
|
||||
# Dedup service rows by name (every manager reports the same cluster-global
|
||||
# set); keep the first — placement is identical across managers.
|
||||
svc_by_name: dict[str, object] = {}
|
||||
for s in services:
|
||||
name = getattr(s, "service_name", None)
|
||||
if name and name not in svc_by_name:
|
||||
svc_by_name[name] = s
|
||||
|
||||
# Real containers grouped by service name.
|
||||
cs_by_service: dict[str, list] = {}
|
||||
for c in swarm_cs:
|
||||
cs_by_service.setdefault(c.service_name, []).append(c)
|
||||
|
||||
def _host_of(c):
|
||||
return (host_name.get(getattr(c, "host_id", None))
|
||||
or node_hostname.get(getattr(c, "node_id", None))
|
||||
or getattr(c, "host_id", None) or "?")
|
||||
|
||||
out_services = []
|
||||
for name in sorted(set(svc_by_name) | set(cs_by_service)):
|
||||
svc = svc_by_name.get(name)
|
||||
reals = cs_by_service.get(name, [])
|
||||
|
||||
replicas = []
|
||||
for c in sorted(reals, key=lambda c: (_host_of(c).lower(), c.name)):
|
||||
replicas.append({
|
||||
"ghost": False,
|
||||
"host": _host_of(c),
|
||||
"host_id": getattr(c, "host_id", None),
|
||||
"name": c.name,
|
||||
"status": getattr(c, "status", "unknown"),
|
||||
"cpu_pct": getattr(c, "cpu_pct", None),
|
||||
"mem_pct": getattr(c, "mem_pct", None),
|
||||
"health": getattr(c, "health", None),
|
||||
"restart_count": getattr(c, "restart_count", 0) or 0,
|
||||
})
|
||||
|
||||
# Real running replicas per node, to subtract from placement.
|
||||
real_running_by_node: dict[str, int] = {}
|
||||
for c in reals:
|
||||
if (getattr(c, "status", "") or "").lower() == "running":
|
||||
nid = getattr(c, "node_id", None)
|
||||
if nid:
|
||||
real_running_by_node[nid] = real_running_by_node.get(nid, 0) + 1
|
||||
|
||||
ghosts = []
|
||||
for p in _placement(svc) if svc is not None else []:
|
||||
nid = p.get("node_id", "")
|
||||
placed = int(p.get("running", 0) or 0)
|
||||
ghost = placed - real_running_by_node.get(nid, 0)
|
||||
if ghost > 0:
|
||||
ghosts.append({
|
||||
"ghost": True,
|
||||
"host": node_hostname.get(nid) or (nid[:12] if nid else "?"),
|
||||
"count": ghost,
|
||||
})
|
||||
ghosts.sort(key=lambda g: g["host"].lower())
|
||||
replicas.extend(ghosts)
|
||||
|
||||
running = getattr(svc, "running", None) if svc is not None else None
|
||||
desired = getattr(svc, "desired", None) if svc is not None else None
|
||||
# No service row (manager didn't report it): infer from what we can see.
|
||||
if running is None:
|
||||
running = sum(1 for r in replicas
|
||||
if (not r["ghost"] and (r["status"] or "").lower() == "running"))
|
||||
if desired is None:
|
||||
desired = len(replicas)
|
||||
|
||||
out_services.append({
|
||||
"name": name,
|
||||
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
|
||||
"running": running,
|
||||
"desired": desired,
|
||||
"state": _service_state(int(running or 0), int(desired or 0)),
|
||||
"replicas": replicas,
|
||||
})
|
||||
|
||||
# Non-swarm containers grouped by host, running-first order preserved.
|
||||
groups: dict[str, dict] = {}
|
||||
for c in standalone_cs:
|
||||
hid = getattr(c, "host_id", None)
|
||||
g = groups.get(hid)
|
||||
if g is None:
|
||||
g = groups[hid] = {"host_id": hid, "host": host_name.get(hid) or hid or "?",
|
||||
"containers": []}
|
||||
g["containers"].append(c)
|
||||
standalone = sorted(groups.values(), key=lambda g: g["host"].lower())
|
||||
|
||||
return {"services": out_services, "standalone": standalone}
|
||||
@@ -14,12 +14,61 @@
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Total</div>
|
||||
<span class="stat-val">{{ total }}</span>
|
||||
</div>
|
||||
{% if swarm_services %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Services</div>
|
||||
<span class="stat-val">{{ swarm_services | length }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.35rem;">Hosts</div>
|
||||
<span class="stat-val">{{ host_groups | length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Swarm services: collapsed per-service, each replica labelled with its host.
|
||||
Ghost replicas (tasks on nodes with no Steward agent) come from the managers'
|
||||
placement data so the picture is cluster-complete. ──────────────────────── #}
|
||||
{% if swarm_services %}
|
||||
<div class="card-flush" style="margin-bottom:1.5rem;">
|
||||
<div style="display:flex;align-items:baseline;gap:0.6rem;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">
|
||||
<span style="font-weight:600;font-size:0.95rem;">Swarm services</span>
|
||||
<a href="/plugins/docker/swarm" style="font-size:0.78rem;color:var(--text-muted);">Topology →</a>
|
||||
</div>
|
||||
<div style="padding:0.5rem 0.75rem;display:grid;gap:0.85rem;">
|
||||
{% for s in swarm_services %}
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:0.55rem;flex-wrap:wrap;margin-bottom:0.35rem;">
|
||||
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="font-weight:600;font-size:0.9rem;">{{ s.name }}</span>
|
||||
<span style="font-size:0.72rem;color:var(--text-dim);">{{ s.mode }}</span>
|
||||
<span style="font-size:0.78rem;color:{% if s.state == 'healthy' %}var(--green){% elif s.state == 'degraded' %}var(--orange){% else %}var(--red){% endif %};font-variant-numeric:tabular-nums;">
|
||||
{{ s.running }}/{{ s.desired }} running
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:0.4rem;padding-left:1.1rem;">
|
||||
{% for r in s.replicas %}
|
||||
{% if r.ghost %}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:5px;padding:0.3rem 0.55rem;" title="Running on a node with no Steward agent — detail unavailable">
|
||||
<span class="dot dot-warn" style="opacity:0.6;"></span>
|
||||
<span style="font-weight:500;">{{ r.host }}</span>
|
||||
<span style="color:var(--text-dim);margin-left:auto;">{{ r.count }} · no agent</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.8rem;background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;padding:0.3rem 0.55rem;">
|
||||
<span class="dot {% if r.status == 'running' %}dot-up{% elif r.status == 'paused' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<a href="/plugins/docker/container/{{ r.host_id }}/{{ r.name }}" style="font-weight:500;color:inherit;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ r.name }}">{{ r.host }}</a>
|
||||
{% if r.cpu_pct is not none %}<span style="color:var(--text-muted);font-family:ui-monospace,monospace;margin-left:auto;">{{ "%.0f" | format(r.cpu_pct) }}%</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Container tables, one per host ───────────────────────────────────────── #}
|
||||
{% if host_groups %}
|
||||
{% for g in host_groups %}
|
||||
@@ -114,7 +163,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% elif not swarm_services %}
|
||||
<div class="card" style="text-align:center;padding:3rem;">
|
||||
<div style="color:var(--text-muted);font-size:0.9rem;">
|
||||
No containers reported yet. Containers are collected by the Steward host agent —
|
||||
|
||||
@@ -14,6 +14,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Swarm services — collapsed, each with its replicas' host chips (dashed = a
|
||||
node with no Steward agent, count-only from placement). #}
|
||||
{% if swarm_services %}
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">Swarm services</div>
|
||||
<div style="display:grid;gap:0.4rem;margin-bottom:0.4rem;">
|
||||
{% for s in swarm_services %}
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.82rem;">
|
||||
<span class="dot {% if s.state == 'healthy' %}dot-up{% elif s.state == 'degraded' %}dot-warn{% else %}dot-down{% endif %}"></span>
|
||||
<span style="font-weight:500;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ s.name }}</span>
|
||||
<span style="color:var(--text-muted);font-family:ui-monospace,monospace;font-size:0.74rem;margin-left:auto;flex-shrink:0;">{{ s.running }}/{{ s.desired }}</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.25rem;margin:0.2rem 0 0 1rem;">
|
||||
{% for r in s.replicas %}
|
||||
{% if r.ghost %}
|
||||
<span title="no Steward agent on this node — count from swarm placement" style="font-size:0.68rem;color:var(--text-dim);background:var(--bg-elevated);border:1px dashed var(--border-mid);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }} ×{{ r.count }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:4px;padding:0.05rem 0.35rem;">{{ r.host }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for g in host_groups %}
|
||||
{% if multi_host %}
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin:0.5rem 0 0.2rem;">{{ g.host_name }}</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for the swarm-aware container view builder (pure, no DB/ORM)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from plugins.docker.swarm_view import build_swarm_services
|
||||
|
||||
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
|
||||
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
|
||||
|
||||
|
||||
def _c(host_id, name, *, service=None, node=None, status="running", cpu=1.0):
|
||||
return SimpleNamespace(host_id=host_id, name=name, service_name=service,
|
||||
node_id=node, status=status, cpu_pct=cpu, mem_pct=2.0,
|
||||
health=None, restart_count=0, container_id=name)
|
||||
|
||||
|
||||
def _svc(name, running, desired, placement, mode="replicated"):
|
||||
import json
|
||||
return SimpleNamespace(service_name=name, running=running, desired=desired,
|
||||
mode=mode, placement_json=json.dumps(placement))
|
||||
|
||||
|
||||
def test_real_replicas_plus_agentless_ghost():
|
||||
containers = [
|
||||
_c("h1", "web.1.aaa", service="web", node="nA"),
|
||||
_c("h2", "web.2.bbb", service="web", node="nB"),
|
||||
_c("h1", "db", node=None), # standalone, non-swarm
|
||||
]
|
||||
services = [_svc("web", running=3, desired=3,
|
||||
placement=[{"node_id": "nA", "running": 1},
|
||||
{"node_id": "nB", "running": 1},
|
||||
{"node_id": "nC", "running": 1}])] # nC has no agent
|
||||
|
||||
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||
|
||||
assert len(view["services"]) == 1
|
||||
svc = view["services"][0]
|
||||
assert svc["name"] == "web" and svc["state"] == "healthy"
|
||||
reals = [r for r in svc["replicas"] if not r["ghost"]]
|
||||
ghosts = [r for r in svc["replicas"] if r["ghost"]]
|
||||
assert sorted(r["host"] for r in reals) == ["docker01", "docker02"]
|
||||
assert len(ghosts) == 1 and ghosts[0]["host"] == "docker03" and ghosts[0]["count"] == 1
|
||||
|
||||
# The non-swarm container is grouped by host, untouched.
|
||||
assert len(view["standalone"]) == 1
|
||||
g = view["standalone"][0]
|
||||
assert g["host"] == "docker01" and [c.name for c in g["containers"]] == ["db"]
|
||||
|
||||
|
||||
def test_placement_fully_covered_has_no_ghosts():
|
||||
containers = [_c("h1", "api.1.x", service="api", node="nA"),
|
||||
_c("h2", "api.2.y", service="api", node="nB")]
|
||||
services = [_svc("api", 2, 2, [{"node_id": "nA", "running": 1},
|
||||
{"node_id": "nB", "running": 1}])]
|
||||
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||
assert all(not r["ghost"] for r in view["services"][0]["replicas"])
|
||||
|
||||
|
||||
def test_multiple_tasks_same_node_partially_agentless():
|
||||
# Placement says 2 running on nA, but we only have 1 local row → 1 ghost on nA.
|
||||
containers = [_c("h1", "cache.1.x", service="cache", node="nA")]
|
||||
services = [_svc("cache", 2, 2, [{"node_id": "nA", "running": 2}])]
|
||||
view = build_swarm_services(containers, services, NODE_HOSTNAME, HOST_NAME)
|
||||
ghosts = [r for r in view["services"][0]["replicas"] if r["ghost"]]
|
||||
assert len(ghosts) == 1 and ghosts[0]["count"] == 1 and ghosts[0]["host"] == "docker01"
|
||||
|
||||
|
||||
def test_service_without_a_service_row_is_synthesised():
|
||||
# Container labeled with a service, but no manager reported the service row.
|
||||
containers = [_c("h1", "orphan.1.x", service="orphan", node="nA")]
|
||||
view = build_swarm_services(containers, [], NODE_HOSTNAME, HOST_NAME)
|
||||
svc = view["services"][0]
|
||||
assert svc["name"] == "orphan" and svc["running"] == 1 and svc["desired"] == 1
|
||||
assert all(not r["ghost"] for r in svc["replicas"])
|
||||
|
||||
|
||||
def test_down_service_state():
|
||||
services = [_svc("idle", 0, 2, [])]
|
||||
view = build_swarm_services([], services, NODE_HOSTNAME, HOST_NAME)
|
||||
assert view["services"][0]["state"] == "down"
|
||||
Reference in New Issue
Block a user