2 Commits
6 changed files with 104 additions and 20 deletions

No files matched your search

+35
View File
@@ -7,6 +7,41 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
--- ---
## [0.9.5] — 2026-10-28
### Fixed
- **A stack redeploy no longer freezes Inspector.** After a successful Portainer
redeploy, Inspector used to stop everything for `post_redeploy_wait_seconds`
(default 90 s): no other container was checked, dashboard times stood still, and
stopping Inspector during the wait wasn't clean. Now the redeployed app is left to
settle while every other container keeps being checked, and its one-time HTTP
re-check (with the "recheck failed" alert) happens on its first check after the wait.
- The redeployed app's cooldown now starts after the settle time. Before, the wait
ate into it (with the defaults, 300 s cooldown became 210 s).
---
## [0.9.4] — 2026-10-28
### Fixed
- **Containers without a Docker health check now "recover" properly.** Many images
don't define a health check, so Docker reports them as `NO HEALTH CHECK` even when
they work. Inspector only treated `HEALTHY` as back to normal, so for these
containers:
- the error count never reset, and one-off failures days apart added up to a restart;
- no "recovered" alert was sent;
- with *Restart, then redeploy*, the next unrelated problem skipped the restart and
redeployed the whole stack.
Now `NO HEALTH CHECK` counts as working (unless you made it a restart trigger): the
count resets, the "recovered" alert is sent ("back to running (no health check)"),
and the next problem starts with a plain restart. Containers with a health check
behave exactly as before.
---
## [0.9.3] — 2026-10-28 ## [0.9.3] — 2026-10-28
### Changed ### Changed
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.9.3" __version__ = "0.9.5"
+3 -2
View File
@@ -356,8 +356,9 @@ folder (or a backup of it) can read the token.</p>
<tr><td>stack_redeploy_cooldown_seconds</td><td>Minimum time between two redeploys of the same <tr><td>stack_redeploy_cooldown_seconds</td><td>Minimum time between two redeploys of the same
stack (default 300). Several apps often share a stack; this stops them triggering a redeploy stack (default 300). Several apps often share a stack; this stops them triggering a redeploy
each.</td></tr> each.</td></tr>
<tr><td>post_redeploy_wait_seconds</td><td>How long Inspector waits after a redeploy for the <tr><td>post_redeploy_wait_seconds</td><td>How long Inspector gives the stack to come up after
stack to come up before judging it again (default 90).</td></tr> a redeploy before judging that app again (default 90). Everything else keeps being checked
meanwhile.</td></tr>
</table> </table>
<h3>If Test connection fails</h3> <h3>If Test connection fails</h3>
+32 -8
View File
@@ -41,7 +41,7 @@ from .maintenance import MaintenanceTracker
from .notification_mute import NotificationMuteTracker from .notification_mute import NotificationMuteTracker
from .notifier import GotifyNotifier, MultiNotifier, NullNotifier, PushoverNotifier from .notifier import GotifyNotifier, MultiNotifier, NullNotifier, PushoverNotifier
from .portainer_client import PortainerClient from .portainer_client import PortainerClient
from .recovery import perform_recovery from .recovery import perform_recovery, run_post_redeploy_recheck
from .uptime_kuma import HeartbeatSnapshot, UptimeKumaClient from .uptime_kuma import HeartbeatSnapshot, UptimeKumaClient
from .state import ( from .state import (
ContainerState, ContainerState,
@@ -63,6 +63,19 @@ def _request_stop(signum, _frame):
_stop = True _stop = True
def _is_normal(status: str | None, container_config: ContainerConfig) -> bool:
"""True when `status` means the container is working.
HEALTHY always counts. NO HEALTH CHECK (running, but the image defines no
health check) counts too, unless the user made it a restart trigger.
Many images have no health check, and without this their error count and
restart episode would never reset.
"""
if status == HEALTHY:
return True
return status == NO_HEALTH_CHECK and NO_HEALTH_CHECK not in container_config.restart_on
def _apply_reachability_override( def _apply_reachability_override(
container_config: ContainerConfig, container_config: ContainerConfig,
result: HealthResult, result: HealthResult,
@@ -140,6 +153,11 @@ def _check_one(
if notification_mute.is_muted(name): if notification_mute.is_muted(name):
notifier = NullNotifier() notifier = NullNotifier()
if state.redeploy_recheck_at is not None and now >= state.redeploy_recheck_at:
run_post_redeploy_recheck(
container_config=container_config, state=state, notifier=notifier, now=now,
)
if state.previous_status != state.current_status: if state.previous_status != state.current_status:
log.info( log.info(
"%-*s status: %s -> %s%s", "%-*s status: %s -> %s%s",
@@ -158,17 +176,23 @@ def _check_one(
message=result.message, message=result.message,
) )
is_normal = _is_normal(state.current_status, container_config)
if ( if (
state.previous_status is not None state.previous_status is not None
and state.previous_status != HEALTHY and not _is_normal(state.previous_status, container_config)
and state.current_status == HEALTHY and is_normal
# HEALTHY here means "a recovery was already announced", for either status.
and state.last_notification_state != HEALTHY and state.last_notification_state != HEALTHY
): ):
log.info("%-*s recovered: %s -> HEALTHY", width, name, state.previous_status) log.info("%-*s recovered: %s -> %s", width, name, state.previous_status, state.current_status)
back_to = (
"**HEALTHY**" if state.current_status == HEALTHY
else "**running** (no health check)"
)
notifier.notify( notifier.notify(
title=f"✅ Inspector: {name} recovered", title=f"✅ Inspector: {name} recovered",
message=( message=(
f"**{name}** is back to **HEALTHY** (was *{state.previous_status}*).\n\n" f"**{name}** is back to {back_to} (was *{state.previous_status}*).\n\n"
f"- Errors before recovery: **{state.error_count}**\n" f"- Errors before recovery: **{state.error_count}**\n"
f"- Time: {format_timestamp(now, with_zone=True)}" f"- Time: {format_timestamp(now, with_zone=True)}"
), ),
@@ -178,7 +202,7 @@ def _check_one(
container=name, container=name,
event_type=EVENT_RECOVERY, event_type=EVENT_RECOVERY,
old_status=state.previous_status, old_status=state.previous_status,
new_status=HEALTHY, new_status=state.current_status,
error_count=state.error_count, error_count=state.error_count,
) )
state.last_notification_state = HEALTHY state.last_notification_state = HEALTHY
@@ -253,9 +277,9 @@ def _check_one(
message=result.message, message=result.message,
) )
state.last_notification_state = result.status state.last_notification_state = result.status
elif result.status == HEALTHY: elif is_normal:
if state.error_count != 0: if state.error_count != 0:
log.info("%-*s healthy again; resetting error count", width, name) log.info("%-*s back to normal; resetting error count", width, name)
state.error_count = 0 state.error_count = 0
state.restarted_this_episode = False state.restarted_this_episode = False
+31 -9
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .config import AppConfig, ContainerConfig, PortainerConfig from .config import AppConfig, ContainerConfig, PortainerConfig
@@ -175,7 +174,8 @@ def _perform_restart_then_redeploy(
- the container is still failing on a later recovery cycle - the container is still failing on a later recovery cycle
(`state.restarted_this_episode` is already set), meaning the restart didn't stick. (`state.restarted_this_episode` is already set), meaning the restart didn't stick.
The episode flag resets when the container is next seen HEALTHY (see main._check_one). The episode flag resets when the container is next seen working: HEALTHY, or
NO HEALTH CHECK when that isn't a restart trigger (see main._is_normal).
""" """
name = container_config.name name = container_config.name
@@ -521,24 +521,46 @@ def _perform_portainer_redeploy(
priority=4, priority=4,
) )
log.info("waiting %d seconds after stack redeploy", wait_seconds) # Don't block the loop while the stack settles: keep this container in
time.sleep(wait_seconds) # cooldown for the wait plus its normal cooldown, and do the one-time HTTP
# re-check on its first regular check after the wait (main._check_one).
log.info(
"%-*s letting stack %s settle for %ds; other containers keep being checked",
width, name, key, wait_seconds,
)
state.record_restart(now, wait_seconds + container_config.cooldown_seconds)
if container_config.http_check.enabled: if container_config.http_check.enabled:
state.redeploy_recheck_at = now + timedelta(seconds=wait_seconds)
def run_post_redeploy_recheck(
*,
container_config: ContainerConfig,
state: ContainerState,
notifier: GotifyNotifier,
now: datetime,
) -> None:
"""One HTTP re-check after a stack redeploy has had time to settle.
Called by main._check_one once `state.redeploy_recheck_at` has passed;
alerts if the app still doesn't answer.
"""
state.redeploy_recheck_at = None
name = container_config.name
if not container_config.http_check.enabled:
return
recheck = check_http(container_config.http_check) recheck = check_http(container_config.http_check)
if recheck.ok: if recheck.ok:
log.info("recheck succeeded for %s", name) log.info("recheck succeeded for %s", name)
else: return
log.warning("recheck failed for %s: %s", name, recheck.message) log.warning("recheck failed for %s: %s", name, recheck.message)
notifier.notify( notifier.notify(
title=f"⚠️ Inspector: {name} recheck failed after redeploy", title=f"⚠️ Inspector: {name} recheck failed after redeploy",
message=( message=(
f"Portainer stack **{stack_name}** was redeployed, but the HTTP recheck " f"Portainer stack **{container_config.portainer_stack_name}** was redeployed, but the HTTP recheck "
f"for **{name}** still reports a failure.\n\n" f"for **{name}** still reports a failure.\n\n"
f"- Recheck result: *{recheck.message}*\n" f"- Recheck result: *{recheck.message}*\n"
f"- Time: {format_timestamp(now, with_zone=True)}" f"- Time: {format_timestamp(now, with_zone=True)}"
), ),
priority=6, priority=6,
) )
state.record_restart(now, container_config.cooldown_seconds)
+2
View File
@@ -29,6 +29,8 @@ class ContainerState:
restart_limit_notified: bool = False restart_limit_notified: bool = False
restarted_this_episode: bool = False restarted_this_episode: bool = False
last_check_at: datetime | None = None last_check_at: datetime | None = None
# After a stack redeploy: when to do the one-time HTTP re-check.
redeploy_recheck_at: datetime | None = None
def in_cooldown(self, now: datetime) -> bool: def in_cooldown(self, now: datetime) -> bool:
return self.cooldown_until is not None and now < self.cooldown_until return self.cooldown_until is not None and now < self.cooldown_until