Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9c57b7d7 | ||
|
|
6aed0cf9a7 |
No files matched your search
@@ -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 @@
|
|||||||
__version__ = "0.9.3"
|
__version__ = "0.9.5"
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user