mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
Merge remote-tracking branch 'origin/main' into docs/tutorials-batch-1
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.95.0"
|
||||
".": "0.96.0"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.96.0](https://github.com/Studio-Saelix/sencho/compare/v0.95.0...v0.96.0) (2026-07-26)
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
* add Admiral Hardened Build channel and business assurance surfaces ([#1629](https://github.com/Studio-Saelix/sencho/issues/1629)) ([381ed2a](https://github.com/Studio-Saelix/sencho/commit/381ed2a91fa7ff5743a365511d9f5cd6bff141a9))
|
||||
* add Apprise as a fourth notification channel ([#1644](https://github.com/Studio-Saelix/sencho/issues/1644)) ([83b3d93](https://github.com/Studio-Saelix/sencho/commit/83b3d932e5a9664264b4b5ac40d601925654e4b1))
|
||||
* add configurable notification dispatch retries ([#1655](https://github.com/Studio-Saelix/sencho/issues/1655)) ([090a0d7](https://github.com/Studio-Saelix/sencho/commit/090a0d73acbad1a85db1603372d79b65e044392d))
|
||||
* add developer-mode startup and stack hydration timing ([#1619](https://github.com/Studio-Saelix/sencho/issues/1619)) ([b70a529](https://github.com/Studio-Saelix/sencho/commit/b70a529656a232df0012c56b3280aed9aa49c4fa))
|
||||
* add service-scoped Compose update and restore ([#1648](https://github.com/Studio-Saelix/sencho/issues/1648)) ([63213c0](https://github.com/Studio-Saelix/sencho/commit/63213c096026551f9fb3b10ada625eb2be8ff35f))
|
||||
* add service-scoped stack alert rules ([#1681](https://github.com/Studio-Saelix/sencho/issues/1681)) ([85842cc](https://github.com/Studio-Saelix/sencho/commit/85842cc54788b453071890fb57764ab608de78a0))
|
||||
* **blueprints:** require confirmed rollout preview before reconcile ([#1649](https://github.com/Studio-Saelix/sencho/issues/1649)) ([d94e586](https://github.com/Studio-Saelix/sencho/commit/d94e586af3011733af5f2d9c991c745385b7b29e))
|
||||
* graduate Host Console to Community admins ([#1669](https://github.com/Studio-Saelix/sencho/issues/1669)) ([dd54a2e](https://github.com/Studio-Saelix/sencho/commit/dd54a2e483f4432c59f602ea1129a47ec87c1ec8))
|
||||
* guide missing external network creation during deploy ([#1645](https://github.com/Studio-Saelix/sencho/issues/1645)) ([35bb744](https://github.com/Studio-Saelix/sencho/commit/35bb74425be6d906d13dc9a2fd67f7c8acd5b534))
|
||||
* node-scoped Networking operator page ([#1603](https://github.com/Studio-Saelix/sencho/issues/1603)) ([8980910](https://github.com/Studio-Saelix/sencho/commit/8980910153c1c6264af35076dbeb64fca0f3301e))
|
||||
* stack glob patterns and route severity levels ([#1651](https://github.com/Studio-Saelix/sencho/issues/1651)) ([972f2b9](https://github.com/Studio-Saelix/sencho/commit/972f2b9483432648689ba01aa7a2d64bb0eed917))
|
||||
* surface stack Monitor from header and service cards ([#1693](https://github.com/Studio-Saelix/sencho/issues/1693)) ([524cc56](https://github.com/Studio-Saelix/sencho/commit/524cc56d2ff43d1d38b737c0a8751509e72d29bc))
|
||||
* **ui:** add Classic, Smart, and Compact desktop navigation styles ([#1642](https://github.com/Studio-Saelix/sencho/issues/1642)) ([25586fc](https://github.com/Studio-Saelix/sencho/commit/25586fc8ab8a8fdd8d2ef7b23d57b41be6c11d83))
|
||||
* weekly UTC maintenance windows for mute rules ([#1661](https://github.com/Studio-Saelix/sencho/issues/1661)) ([a3edee5](https://github.com/Studio-Saelix/sencho/commit/a3edee5e6a6c6319bb292f2e37d96aa3260acf3f))
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
* assorted UI/UX polish fixes ([#1670](https://github.com/Studio-Saelix/sencho/issues/1670)) ([cfb42af](https://github.com/Studio-Saelix/sencho/commit/cfb42af4e098ce8375529eff9c78c33b17c0e9fa))
|
||||
* **blueprints:** fail closed on marker ownership for apply and withdraw ([#1694](https://github.com/Studio-Saelix/sencho/issues/1694)) ([17a8dc8](https://github.com/Studio-Saelix/sencho/commit/17a8dc8a94b18b396492735de8cd89bc2cf45c97))
|
||||
* **blueprints:** gate confirmed apply on live intent fingerprint ([#1663](https://github.com/Studio-Saelix/sencho/issues/1663)) ([155db30](https://github.com/Studio-Saelix/sencho/commit/155db30554e566fcb1a449451fc79b6e6bb4e3d9))
|
||||
* **blueprints:** write compose.yaml so first-time apply is not shadowed ([#1668](https://github.com/Studio-Saelix/sencho/issues/1668)) ([e15b9d1](https://github.com/Studio-Saelix/sencho/commit/e15b9d1244f1e59f7140aaa2139fe34a16f681bb))
|
||||
* bump brace-expansion override to 5.0.8 to clear high-severity audit ([#1702](https://github.com/Studio-Saelix/sencho/issues/1702)) ([d0a4f1e](https://github.com/Studio-Saelix/sencho/commit/d0a4f1ead9652667580319e1c83f31baea675060))
|
||||
* dedupe healthcheck alerts and share crash rate limits ([#1690](https://github.com/Studio-Saelix/sencho/issues/1690)) ([ec0f59a](https://github.com/Studio-Saelix/sencho/commit/ec0f59a85e3a54f29670d2424e4e04d21b07d6c4))
|
||||
* **docs:** remove duplicate license url key in openapi.yaml ([#1628](https://github.com/Studio-Saelix/sencho/issues/1628)) ([8ca8eba](https://github.com/Studio-Saelix/sencho/commit/8ca8ebaa2468ad6df79bd0be87a94dba6c4faf15))
|
||||
* **fleet:** expose Community cordon on NodeCard ([#1646](https://github.com/Studio-Saelix/sencho/issues/1646)) ([674220b](https://github.com/Studio-Saelix/sencho/commit/674220b9deaee8f4e8a58626fdf1d0679a7c669b))
|
||||
* **fleet:** isolate corrupt snapshot file decrypt failures ([#1650](https://github.com/Studio-Saelix/sencho/issues/1650)) ([3b02795](https://github.com/Studio-Saelix/sencho/commit/3b027957c4369a4d8c0ea5513b9e631b1bf2508b))
|
||||
* **fleet:** refresh prune reclaimable estimate after successful run ([#1675](https://github.com/Studio-Saelix/sencho/issues/1675)) ([ed5ca9c](https://github.com/Studio-Saelix/sencho/commit/ed5ca9c4f690d07dc1ccf25c3a8557e57780410d))
|
||||
* **fleet:** verify update status before removing readiness cards ([#1697](https://github.com/Studio-Saelix/sencho/issues/1697)) ([719180f](https://github.com/Studio-Saelix/sencho/commit/719180f156c49bea48ad2b319d81cb9823cfb9fa))
|
||||
* **image-updates:** match any local RepoDigest against the remote tag ([#1695](https://github.com/Studio-Saelix/sencho/issues/1695)) ([6688da9](https://github.com/Studio-Saelix/sencho/commit/6688da97b1e3b107b379539354ceddc56b549b7d))
|
||||
* **image-updates:** normalize docker.io host aliases to the registry API host ([#1706](https://github.com/Studio-Saelix/sencho/issues/1706)) ([bb7c76b](https://github.com/Studio-Saelix/sencho/commit/bb7c76ba4664abcdfafcdc5816e35073076c66d7))
|
||||
* **image-updates:** treat multi-arch child digests as up to date ([#1641](https://github.com/Studio-Saelix/sencho/issues/1641)) ([66ec4eb](https://github.com/Studio-Saelix/sencho/commit/66ec4ebdd2c6c67576f6e4c328bd7fe87c28285c))
|
||||
* keep running containers until stack pull/build succeeds ([#1657](https://github.com/Studio-Saelix/sencho/issues/1657)) ([3f1f15a](https://github.com/Studio-Saelix/sencho/commit/3f1f15a6f433eb137206833550c2eb0a0134fbf0))
|
||||
* leave editor after deleting the open stack ([#1665](https://github.com/Studio-Saelix/sencho/issues/1665)) ([55fa29f](https://github.com/Studio-Saelix/sencho/commit/55fa29f565473865f4c262f2b8516bc1899faa82))
|
||||
* **notifications:** align Sencho update alerts with Fleet cache ([#1620](https://github.com/Studio-Saelix/sencho/issues/1620)) ([4079cb9](https://github.com/Studio-Saelix/sencho/commit/4079cb919817509e17ba4ed9f13889be892a09e0))
|
||||
* **notifications:** neutralize satellite-local node names in alert bodies ([#1640](https://github.com/Studio-Saelix/sencho/issues/1640)) ([d8e4ede](https://github.com/Studio-Saelix/sencho/commit/d8e4ede94f1fcacc631e70eaca5ec3a8540951e5))
|
||||
* **notifications:** stop embedding Local in janitor alerts ([#1631](https://github.com/Studio-Saelix/sencho/issues/1631)) ([678c198](https://github.com/Studio-Saelix/sencho/commit/678c198faadc065c081013590dd810dfbcf35242))
|
||||
* **notifications:** version mute replica retractions for soft-cleanup restore ([#1703](https://github.com/Studio-Saelix/sencho/issues/1703)) ([9859ce6](https://github.com/Studio-Saelix/sencho/commit/9859ce60b813fd3fc5247234a1f21ac7259eb9e3))
|
||||
* pin brace-expansion to a patched version in both packages ([#1658](https://github.com/Studio-Saelix/sencho/issues/1658)) ([8598390](https://github.com/Studio-Saelix/sencho/commit/859839082c89f912b346b11a42acfc0576268314))
|
||||
* pin postcss override to clear backend audit vulnerability ([#1701](https://github.com/Studio-Saelix/sencho/issues/1701)) ([e33eda3](https://github.com/Studio-Saelix/sencho/commit/e33eda3c38423b047f1c870ea7a452cf11b1741f))
|
||||
* prevent false empty states during stack hydration ([#1659](https://github.com/Studio-Saelix/sencho/issues/1659)) ([b06dfd7](https://github.com/Studio-Saelix/sencho/commit/b06dfd71754242e6a09104aa9b61f38ba1bcbc7c))
|
||||
* purge deleted-stack notifications from panel and ticker ([#1674](https://github.com/Studio-Saelix/sencho/issues/1674)) ([698b7d0](https://github.com/Studio-Saelix/sencho/commit/698b7d0713919b9f91a63e05d84b44057b7f1d12))
|
||||
* **rate-limit:** verify node_proxy JWT before skipping limiters ([#1647](https://github.com/Studio-Saelix/sencho/issues/1647)) ([31d4e46](https://github.com/Studio-Saelix/sencho/commit/31d4e4669b335ce270cece716045b34072385e7b))
|
||||
* **rbac:** cover stack assignment cleanup on blueprint withdraw ([#1664](https://github.com/Studio-Saelix/sencho/issues/1664)) ([6484c79](https://github.com/Studio-Saelix/sencho/commit/6484c79015b5da7321379a76bc34d43478eb7dd0))
|
||||
* recognize clean one-shot completions in health gate and drift ([#1691](https://github.com/Studio-Saelix/sencho/issues/1691)) ([79914fe](https://github.com/Studio-Saelix/sencho/commit/79914fe7503923edebe64e9f006b4fbe5b7d189f))
|
||||
* reconcile sticky update indicators with Anatomy preview ([#1698](https://github.com/Studio-Saelix/sencho/issues/1698)) ([0daddfd](https://github.com/Studio-Saelix/sencho/commit/0daddfde00dfe37a8e0622f639d2a84125c031f3))
|
||||
* **security:** bump grpc to 1.82.1 in docker CLI and compose builds ([#1678](https://github.com/Studio-Saelix/sencho/issues/1678)) ([ce4eeb6](https://github.com/Studio-Saelix/sencho/commit/ce4eeb6b933b299a880dd566d40148c970a78422))
|
||||
* **settings:** toast and block save when node settings fail to load ([#1654](https://github.com/Studio-Saelix/sencho/issues/1654)) ([ad00517](https://github.com/Studio-Saelix/sencho/commit/ad00517a0ea5c9ab1a2b992e0cad622b0f2d4503))
|
||||
* **sso:** correct settings description to drop unsupported SAML claim ([#1662](https://github.com/Studio-Saelix/sencho/issues/1662)) ([a4d9fa7](https://github.com/Studio-Saelix/sencho/commit/a4d9fa79a1d6043708fb3f951b58337b2c0abfaa))
|
||||
* **stacks:** fail closed when compose ps errors during update orphan classify ([#1708](https://github.com/Studio-Saelix/sencho/issues/1708)) ([6887670](https://github.com/Studio-Saelix/sencho/commit/688767092a3b4be5a11709fac72ffcd7ffadb299))
|
||||
* **ui:** default Reduced Motion on Calm and quiet decorative rails ([#1622](https://github.com/Studio-Saelix/sencho/issues/1622)) ([c170c3f](https://github.com/Studio-Saelix/sencho/commit/c170c3f30ca0673df1e5e395dcd82dd1baed33b6))
|
||||
* **ui:** hide log service chips on single-service stacks ([#1689](https://github.com/Studio-Saelix/sencho/issues/1689)) ([a89498a](https://github.com/Studio-Saelix/sencho/commit/a89498ae5b4bb8b88178fe8088e1acb46e3821b9))
|
||||
* **ui:** lower-overhead Reduced effects for constrained GPUs ([#1616](https://github.com/Studio-Saelix/sencho/issues/1616)) ([c01f247](https://github.com/Studio-Saelix/sencho/commit/c01f2479b94b2214a949ae736a3c5141cc52b5be))
|
||||
* **ui:** use Docker health status terms on container cards ([#1696](https://github.com/Studio-Saelix/sencho/issues/1696)) ([4d2270a](https://github.com/Studio-Saelix/sencho/commit/4d2270a097b797c632353efaf513b67ccd5f57a6)), closes [#1677](https://github.com/Studio-Saelix/sencho/issues/1677), thanks @Crosis47
|
||||
* unlock Community deploy policy hard-blocking ([#1643](https://github.com/Studio-Saelix/sencho/issues/1643)) ([b91025d](https://github.com/Studio-Saelix/sencho/commit/b91025dc8b5d476dd295f12695f4225a88081b90))
|
||||
|
||||
## [0.95.0](https://github.com/Studio-Saelix/sencho/compare/v0.94.1...v0.95.0) (2026-07-12)
|
||||
|
||||
|
||||
|
||||
+18
-12
@@ -102,14 +102,16 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
||||
# The fetch pulls only the v29.4.1 commit, minimising transfer size.
|
||||
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer
|
||||
# compliance requirements. We copy vendor.mod -> go.mod, drop the committed vendor
|
||||
# tree, bump golang.org/x/net to v0.55.0 and google.golang.org/grpc to v1.82.1,
|
||||
# and build with -mod=mod so the patched modules are resolved from the module
|
||||
# proxy. x/net v0.53.0 is flagged for six HIGH advisories (CVE-2026-25680,
|
||||
# -25681, -27136, -39821, -42502, -42506; x/net/html parsing and x/net/idna).
|
||||
# grpc v1.80.0 is flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2). Removing
|
||||
# vendor/ keeps -mod=mod from reading the stale copy, and avoids `go mod tidy`
|
||||
# (which does not run cleanly against docker/cli's vendor.mod manifest). This
|
||||
# stage now fetches modules at build time rather than building fully offline.
|
||||
# tree, bump golang.org/x/net to v0.55.0, golang.org/x/text to v0.39.0, and
|
||||
# google.golang.org/grpc to v1.82.1, and build with -mod=mod so the patched
|
||||
# modules are resolved from the module proxy. x/net v0.53.0 is flagged for six
|
||||
# HIGH advisories (CVE-2026-25680, -25681, -27136, -39821, -42502, -42506;
|
||||
# x/net/html parsing and x/net/idna). x/text v0.37.0 is flagged for
|
||||
# CVE-2026-56852 (norm.Iter infinite loop on crafted input). grpc v1.80.0 is
|
||||
# flagged for GHSA-hrxh-6v49-42gf (xDS RBAC / HTTP/2). Removing vendor/ keeps
|
||||
# -mod=mod from reading the stale copy, and avoids `go mod tidy` (which does
|
||||
# not run cleanly against docker/cli's vendor.mod manifest). This stage now
|
||||
# fetches modules at build time rather than building fully offline.
|
||||
# Base image pinned by digest so the Go toolchain that compiles the static
|
||||
# Docker CLI binary cannot change without an explicit Dependabot bump.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27rc2-alpine@sha256:dcbb18cc5fa1082364dc6aa95224b6b55429d09cbb9631a053d8064c1c367300 AS cli-builder
|
||||
@@ -134,6 +136,7 @@ RUN mkdir -p /build
|
||||
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
||||
rm -rf vendor && \
|
||||
go get golang.org/x/net@v0.55.0 \
|
||||
golang.org/x/text@v0.39.0 \
|
||||
google.golang.org/grpc@v1.82.1 && \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-mod=mod \
|
||||
@@ -172,9 +175,11 @@ RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
||||
# so this is defense-in-depth rather than a live exposure.
|
||||
#
|
||||
# The same go get also bumps google.golang.org/grpc from v1.80.0 to v1.82.1 to
|
||||
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues).
|
||||
# Base image pinned by digest (same image as cli-builder above) so both
|
||||
# source builds share an identical, immutable Go toolchain.
|
||||
# clear GHSA-hrxh-6v49-42gf (xDS RBAC fail-open and HTTP/2 transport issues),
|
||||
# and golang.org/x/text from v0.38.0 to v0.39.0 to clear CVE-2026-56852
|
||||
# (norm.Iter infinite loop on crafted input). Base image pinned by digest
|
||||
# (same image as cli-builder above) so both source builds share an
|
||||
# identical, immutable Go toolchain.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27rc2-alpine@sha256:dcbb18cc5fa1082364dc6aa95224b6b55429d09cbb9631a053d8064c1c367300 AS compose-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
@@ -212,7 +217,8 @@ RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 \
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0 \
|
||||
github.com/containerd/containerd/v2@v2.2.5 \
|
||||
google.golang.org/grpc@v1.82.1 && \
|
||||
google.golang.org/grpc@v1.82.1 \
|
||||
golang.org/x/text@v0.39.0 && \
|
||||
go mod tidy
|
||||
|
||||
# Build target is ./cmd (the package main with plugin.Run), per docker/compose's
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
## What Sencho is
|
||||
|
||||
Sencho is a Docker Compose control plane for DevOps engineers, platform teams, and system administrators who run services on Compose and need a real operational surface: a graphical interface that does not give up file-on-disk workflows, and the ability to manage more than one machine without SSH gymnastics or a VPN.
|
||||
Sencho is a Docker Compose control plane for DevOps engineers, platform teams, system administrators and homelab users who run services on Compose and need a real operational surface: a graphical interface that does not give up file-on-disk workflows, and the ability to manage more than one machine without SSH gymnastics or a VPN.
|
||||
|
||||
It runs as a single container on your hardware and provides a UI for common Compose operations: deploying, editing files, watching logs, restarting containers, browsing volumes, and recovering from failures. Your compose files stay on the host filesystem and remain the source of truth.
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -2490,16 +2490,16 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"create-emergency-admin": "node dist/cli/createEmergencyAdmin.js",
|
||||
"clear-sessions": "node dist/cli/clearSessions.js",
|
||||
"disable-sso": "node dist/cli/disableSso.js",
|
||||
"enable-local-login": "node dist/cli/enableLocalLogin.js",
|
||||
"diagnostics": "node dist/cli/diagnostics.js",
|
||||
"validate-db": "node dist/cli/validateDb.js",
|
||||
"backup-data": "node dist/cli/backupData.js"
|
||||
@@ -30,7 +31,7 @@
|
||||
"node": ">=26.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "^5.0.7",
|
||||
"brace-expansion": "^5.0.8",
|
||||
"postcss": "^8.5.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Authentication mode (SSO-only) route and activation safeguards.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
loginAsTestAdmin,
|
||||
TEST_USERNAME,
|
||||
TEST_PASSWORD,
|
||||
} from './helpers/setupTestDb';
|
||||
import { setAuthenticationMode } from '../helpers/authenticationMode';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let adminCookie: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let SSOService: typeof import('../services/SSOService').SSOService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
({ SSOService } = await import('../services/SSOService'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
function markAdminAsSso(): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb()
|
||||
.prepare("UPDATE users SET auth_provider = 'oidc_custom', provider_id = 'sso-admin-1' WHERE username = ?")
|
||||
.run(TEST_USERNAME);
|
||||
}
|
||||
|
||||
function markAdminAsLocal(): void {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb()
|
||||
.prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE username = ?")
|
||||
.run(TEST_USERNAME);
|
||||
}
|
||||
|
||||
function enableGithubProvider(): void {
|
||||
DatabaseService.getInstance().upsertSSOConfig(
|
||||
'oidc_github',
|
||||
true,
|
||||
JSON.stringify({
|
||||
provider: 'oidc_github',
|
||||
enabled: true,
|
||||
displayName: 'GitHub',
|
||||
oidcClientId: 'test-client',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
markAdminAsLocal();
|
||||
const db = DatabaseService.getInstance();
|
||||
for (const cfg of db.getSSOConfigs()) {
|
||||
db.upsertSSOConfig(cfg.provider, false, cfg.config_json);
|
||||
}
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
});
|
||||
|
||||
describe('GET /api/sso/auth-mode', () => {
|
||||
it('returns the current mode for an admin', async () => {
|
||||
const res = await request(app).get('/api/sso/auth-mode').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticationMode).toBe('local_and_sso');
|
||||
expect(res.body.localLoginEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/sso/auth-mode', () => {
|
||||
it('rejects PUT sso_only without confirm: true', async () => {
|
||||
markAdminAsSso();
|
||||
enableGithubProvider();
|
||||
vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true });
|
||||
|
||||
const missing = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only' });
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body.error).toMatch(/confirm/i);
|
||||
|
||||
const falsy = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only', confirm: false });
|
||||
expect(falsy.status).toBe(400);
|
||||
expect(falsy.body.error).toMatch(/confirm/i);
|
||||
});
|
||||
|
||||
it('allows Community admin to enable sso_only', async () => {
|
||||
markAdminAsSso();
|
||||
enableGithubProvider();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only', confirm: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticationMode).toBe('sso_only');
|
||||
expect(res.body.localLoginEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects local-only admin entering sso_only', async () => {
|
||||
enableGithubProvider();
|
||||
vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only', confirm: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Sign in with SSO/i);
|
||||
});
|
||||
|
||||
it('rejects sso_only when no provider is enabled', async () => {
|
||||
markAdminAsSso();
|
||||
const res = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only', confirm: true });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/at least one SSO provider/i);
|
||||
});
|
||||
|
||||
it('enables sso_only for an SSO admin when a provider test passes', async () => {
|
||||
markAdminAsSso();
|
||||
enableGithubProvider();
|
||||
vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true });
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'sso_only', confirm: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticationMode).toBe('sso_only');
|
||||
expect(res.body.localLoginEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a Community admin revert to local_and_sso (not paid-gated)', async () => {
|
||||
setAuthenticationMode('sso_only');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/sso/auth-mode')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ mode: 'local_and_sso' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.authenticationMode).toBe('local_and_sso');
|
||||
expect(res.body.localLoginEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Last-provider guard while sso_only', () => {
|
||||
it('rejects disabling the last enabled provider', async () => {
|
||||
markAdminAsSso();
|
||||
enableGithubProvider();
|
||||
setAuthenticationMode('sso_only');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/sso/config/oidc_github')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({
|
||||
provider: 'oidc_github',
|
||||
enabled: false,
|
||||
displayName: 'GitHub',
|
||||
oidcClientId: 'test-client',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/last SSO provider/i);
|
||||
expect(DatabaseService.getInstance().getEnabledSSOConfigs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects deleting the last enabled provider', async () => {
|
||||
markAdminAsSso();
|
||||
enableGithubProvider();
|
||||
setAuthenticationMode('sso_only');
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/sso/config/oidc_github')
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/last SSO provider/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password change under sso_only', () => {
|
||||
it('still allows an authenticated password change', async () => {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
const freshCookie = await loginAsTestAdmin(app);
|
||||
setAuthenticationMode('sso_only');
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/auth/password')
|
||||
.set('Cookie', freshCookie)
|
||||
.send({ oldPassword: TEST_PASSWORD, newPassword: TEST_PASSWORD });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,54 @@ describe('POST /api/auth/login', () => {
|
||||
const res = await request(app).post('/api/auth/login').send({});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 403 when authentication_mode is sso_only', async () => {
|
||||
const { setAuthenticationMode } = await import('../helpers/authenticationMode');
|
||||
setAuthenticationMode('sso_only');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toMatch(/Local password authentication is disabled/i);
|
||||
} finally {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/auth/status', () => {
|
||||
it('reports localLoginEnabled true by default', async () => {
|
||||
const res = await request(app).get('/api/auth/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.localLoginEnabled).toBe(true);
|
||||
expect(res.body.authenticationMode).toBe('local_and_sso');
|
||||
});
|
||||
|
||||
it('reports localLoginEnabled false when sso_only', async () => {
|
||||
const { setAuthenticationMode } = await import('../helpers/authenticationMode');
|
||||
setAuthenticationMode('sso_only');
|
||||
try {
|
||||
const res = await request(app).get('/api/auth/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.localLoginEnabled).toBe(false);
|
||||
expect(res.body.authenticationMode).toBe('sso_only');
|
||||
} finally {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults localLoginEnabled to true when the setting key is missing', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb().prepare('DELETE FROM global_settings WHERE key = ?').run('authentication_mode');
|
||||
// Bust the settings cache so the next read rebuilds without the deleted key.
|
||||
const cpu = db.getDb().prepare('SELECT value FROM global_settings WHERE key = ?').get('host_cpu_limit') as { value: string };
|
||||
db.updateGlobalSetting('host_cpu_limit', cpu.value);
|
||||
const res = await request(app).get('/api/auth/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.localLoginEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenDigestApplyBlockedByCheckErrors,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
|
||||
describe('autoUpdateDigestGate', () => {
|
||||
it('blocks digest apply when sibling check errors exist', () => {
|
||||
const state = createAutoUpdateDigestGateState();
|
||||
recordAutoUpdateImageCheck(state, 'nginx:latest', {
|
||||
hasUpdate: true,
|
||||
digestUpdate: true,
|
||||
tagUpdate: false,
|
||||
});
|
||||
recordAutoUpdateImageCheck(state, 'redis:latest', {
|
||||
hasUpdate: false,
|
||||
digestUpdate: false,
|
||||
tagUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: 'registry timeout',
|
||||
});
|
||||
|
||||
const msg = messageWhenDigestApplyBlockedByCheckErrors('web', state);
|
||||
expect(msg).toContain('image check(s) failed');
|
||||
expect(msg).toContain('registry timeout');
|
||||
});
|
||||
|
||||
it('does not block when digest update exists without check errors', () => {
|
||||
const state = createAutoUpdateDigestGateState();
|
||||
recordAutoUpdateImageCheck(state, 'nginx:latest', {
|
||||
hasUpdate: true,
|
||||
digestUpdate: true,
|
||||
tagUpdate: false,
|
||||
});
|
||||
expect(messageWhenDigestApplyBlockedByCheckErrors('web', state)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -298,6 +298,26 @@ describe('CacheService', () => {
|
||||
it('is a no-op for missing keys', () => {
|
||||
expect(() => cache.invalidate('ns:missing')).not.toThrow();
|
||||
});
|
||||
|
||||
it('prevents an in-flight fetch started before invalidate from committing afterward', async () => {
|
||||
let resolveFetch!: (value: string) => void;
|
||||
const staleFetcher = vi.fn(() => new Promise<string>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}));
|
||||
|
||||
const stalePromise = cache.getOrFetch('fleet-updates', 60_000, staleFetcher);
|
||||
cache.invalidate('fleet-updates');
|
||||
|
||||
const freshFetcher = vi.fn().mockResolvedValue('fresh');
|
||||
const fresh = await cache.getOrFetch('fleet-updates', 60_000, freshFetcher);
|
||||
expect(fresh).toBe('fresh');
|
||||
expect(freshFetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFetch('stale');
|
||||
await expect(stalePromise).resolves.toBe('stale');
|
||||
// Stale writer must not overwrite the post-invalidate entry.
|
||||
expect(cache.get<string>('fleet-updates')).toBe('fresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateNamespace', () => {
|
||||
|
||||
@@ -28,6 +28,9 @@ function stubDocker(
|
||||
rendered: object | null,
|
||||
stderr = '',
|
||||
snapshot: { containers: unknown[]; networks: unknown[]; volumes: unknown[] } | 'reject' = { containers: [], networks: [], volumes: [] },
|
||||
inspectImage: ReturnType<typeof vi.fn> = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('No such image'), { statusCode: 404 }),
|
||||
),
|
||||
) {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
@@ -41,6 +44,13 @@ function stubDocker(
|
||||
getDependencySnapshot: snapshot === 'reject'
|
||||
? vi.fn().mockRejectedValue(new Error('docker down'))
|
||||
: vi.fn().mockResolvedValue(snapshot),
|
||||
getDocker: vi.fn(() => ({
|
||||
listContainers: vi.fn().mockResolvedValue([]),
|
||||
getContainer: vi.fn(() => ({
|
||||
inspect: vi.fn().mockResolvedValue({ Config: {} }),
|
||||
})),
|
||||
})),
|
||||
inspectImage,
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
@@ -85,7 +95,7 @@ describe('runPreflight', () => {
|
||||
expect(report.renderable).toBe(true);
|
||||
expect(report.status).toBe('high'); // env-unset + 0.0.0.0 exposure are high
|
||||
expect(report.highestSeverity).toBe('high');
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'no-healthcheck']));
|
||||
expect(report.findings.map(f => f.ruleId)).toEqual(expect.arrayContaining(['env-literal-dollar', 'port-exposed-all-interfaces', 'image-latest', 'healthcheck-unverifiable']));
|
||||
expect(report.ranBy).toBe('tester');
|
||||
expect(report.sourceHash).toBeTruthy();
|
||||
|
||||
@@ -173,6 +183,30 @@ describe('runPreflight', () => {
|
||||
expect(doctor().getLatest(nodeId, STACK).status).toBe('pass');
|
||||
});
|
||||
|
||||
it('treats inherited healthcheck as a note that does not block All Clear', async () => {
|
||||
const model = {
|
||||
name: STACK,
|
||||
services: { web: { image: 'nginx:1.27', restart: 'always' } },
|
||||
networks: {},
|
||||
volumes: {},
|
||||
};
|
||||
stubDocker(
|
||||
model,
|
||||
'',
|
||||
{ containers: [], networks: [], volumes: [] },
|
||||
vi.fn().mockResolvedValue({
|
||||
inspect: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
history: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const report = await doctor().runPreflight(nodeId, STACK, 'tester');
|
||||
expect(report.findings.some(f => f.ruleId === 'healthcheck-inherited')).toBe(true);
|
||||
expect(report.activeCount).toBe(0);
|
||||
expect(report.activeStatus).toBe('pass');
|
||||
expect(report.status).toBe('pass');
|
||||
});
|
||||
|
||||
it('returns an unrenderable report and never stores raw stderr', async () => {
|
||||
stubDocker(null, `bad yaml near ${SECRET}`); // stderr can echo arbitrary file content
|
||||
const report = await doctor().runPreflight(nodeId, STACK, null);
|
||||
|
||||
@@ -14,10 +14,15 @@ import {
|
||||
import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
|
||||
function effSvc(over: Partial<EffService> = {}): EffService {
|
||||
const hasHealthcheck = over.hasHealthcheck ?? true;
|
||||
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [], ...over,
|
||||
privileged: false, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [],
|
||||
...over,
|
||||
hasHealthcheck,
|
||||
composeHealthcheck,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
* - GET /api/dashboard/configuration returns the documented shape and
|
||||
* applies tier-correct `locked` flags for the Community and paid
|
||||
* personas (toggled via LicenseService spies).
|
||||
* - Alert-rule counts are scoped to stacks present on the active node
|
||||
* (dashboard and fleet local-node row agree on exact cardinality).
|
||||
* - GET /api/dashboard/stack-restarts clamps the `days` query parameter
|
||||
* to [1, 30] and falls back to 7 for invalid inputs.
|
||||
* - Neither endpoint leaks secret material (agent URLs, tokens) in the
|
||||
* response payload.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
@@ -113,7 +117,7 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
};
|
||||
const idA = db.createScheduledTask({
|
||||
...baseTask,
|
||||
@@ -192,6 +196,57 @@ describe('GET /api/dashboard/configuration', () => {
|
||||
db.getDb().prepare('DELETE FROM agents WHERE url = ?').run(SECRET_URL);
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes alertRules to stacks on the active node for dashboard and fleet local row', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const composeDir = process.env.COMPOSE_DIR as string;
|
||||
const stackName = 'cfg-alert-scope';
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
const alertIds: number[] = [];
|
||||
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, 'compose.yaml'),
|
||||
'services:\n web:\n image: nginx:latest\n',
|
||||
);
|
||||
|
||||
const baseAlert = {
|
||||
service_name: null as string | null,
|
||||
metric: 'cpu_percent',
|
||||
operator: '>',
|
||||
threshold: 80,
|
||||
duration_mins: 5,
|
||||
cooldown_mins: 15,
|
||||
last_fired_at: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Two rules on a discovered stack (exact rule count, not unique stacks)
|
||||
// plus one orphaned rule that must not inflate the scoped count.
|
||||
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 80 }).id!);
|
||||
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: stackName, threshold: 90 }).id!);
|
||||
alertIds.push(db.addStackAlert({ ...baseAlert, stack_name: 'cfg-alert-orphan', threshold: 70 }).id!);
|
||||
|
||||
const dash = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie);
|
||||
expect(dash.status).toBe(200);
|
||||
expect(dash.body.notifications.alertRules).toBe(2);
|
||||
|
||||
const fleet = await request(app).get('/api/fleet/configuration').set('Cookie', adminCookie);
|
||||
expect(fleet.status).toBe(200);
|
||||
expect(Array.isArray(fleet.body)).toBe(true);
|
||||
const localRow = fleet.body.find(
|
||||
(row: { type: string; configuration: { notifications: { alertRules: number } } | null }) =>
|
||||
row.type === 'local' && row.configuration != null,
|
||||
);
|
||||
expect(localRow).toBeDefined();
|
||||
expect(localRow.configuration.notifications.alertRules).toBe(2);
|
||||
} finally {
|
||||
for (const id of alertIds) {
|
||||
db.deleteStackAlert(id);
|
||||
}
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/dashboard/stack-restarts', () => {
|
||||
|
||||
@@ -41,6 +41,8 @@ const SCHEMA = `
|
||||
prune_targets TEXT,
|
||||
target_services TEXT,
|
||||
prune_label_filter TEXT,
|
||||
selector_type TEXT,
|
||||
selector_value TEXT,
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
|
||||
@@ -46,10 +46,50 @@ describe('stack_update_status tri-state accessors', () => {
|
||||
expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('keeps getStackUpdateStatus a boolean map for the fleet contract', () => {
|
||||
it('keeps getStackUpdateStatus a raw boolean map ignoring check_status', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
|
||||
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false });
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true });
|
||||
});
|
||||
|
||||
it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom');
|
||||
db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null);
|
||||
expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({
|
||||
web: true,
|
||||
sticky: false,
|
||||
failed: false,
|
||||
clean: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('clearStackUpdateStatus returns deleted row count and removes services_json', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
]);
|
||||
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1);
|
||||
expect(db().getStackUpdateDetail(NODE).web).toBeUndefined();
|
||||
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
|
||||
});
|
||||
|
||||
it('clearAllStackUpdateStatus deletes only the given node rows', () => {
|
||||
const other = NODE + 1;
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'api', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(other, 'web', true, 1000, 'ok', null);
|
||||
expect(db().clearAllStackUpdateStatus(NODE)).toBe(2);
|
||||
expect(db().getStackUpdateDetail(NODE)).toEqual({});
|
||||
expect(db().getStackUpdateDetail(other).web).toBeDefined();
|
||||
});
|
||||
|
||||
it('getNodeUpdateSummary counts only confirmed updates', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
||||
const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE);
|
||||
expect(summary?.stacks_with_updates).toBe(1);
|
||||
});
|
||||
|
||||
it('recordStackCheckFailure preserves a prior has_update while marking failed', () => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Unit tests for DockerController — validateApiData, state-safe container ops,
|
||||
* disk usage, classified resources, orphan detection, and error paths.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const { mockDocker } = vi.hoisted(() => {
|
||||
const { mockDocker, composeDirRef, mockExecFileAsync } = vi.hoisted(() => {
|
||||
const mockDocker = {
|
||||
df: vi.fn(),
|
||||
listImages: vi.fn().mockResolvedValue([]),
|
||||
@@ -23,7 +23,11 @@ const { mockDocker } = vi.hoisted(() => {
|
||||
pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
|
||||
createNetwork: vi.fn(),
|
||||
};
|
||||
return { mockDocker };
|
||||
return {
|
||||
mockDocker,
|
||||
composeDirRef: { current: '/test/compose' },
|
||||
mockExecFileAsync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
@@ -31,7 +35,16 @@ vi.mock('../services/NodeRegistry', () => ({
|
||||
getInstance: () => ({
|
||||
getDocker: () => mockDocker,
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/test/compose',
|
||||
getComposeDir: () => composeDirRef.current,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getGitSource: () => undefined,
|
||||
getStackProjectEnvFiles: () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -42,14 +55,19 @@ vi.mock('child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('util', () => ({
|
||||
promisify: () => vi.fn(),
|
||||
promisify: () => mockExecFileAsync,
|
||||
}));
|
||||
|
||||
import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
composeDirRef.current = '/test/compose';
|
||||
mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
// ── validateApiData ────────────────────────────────────────────────────
|
||||
@@ -1694,13 +1712,23 @@ describe('DockerController - getBulkStackStatuses partial status', () => {
|
||||
|
||||
// ── getLegacyOrphanContainersByStack (#1565) ───────────────────────────
|
||||
|
||||
type OrphanDcSpies = {
|
||||
fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]>;
|
||||
smartFallback: (...a: unknown[]) => Promise<unknown[]>;
|
||||
};
|
||||
|
||||
function spyOrphanDc(
|
||||
dc: ReturnType<typeof DockerController.getInstance>,
|
||||
method: keyof OrphanDcSpies,
|
||||
) {
|
||||
return vi.spyOn(dc as unknown as OrphanDcSpies, method);
|
||||
}
|
||||
|
||||
describe('DockerController - getLegacyOrphanContainersByStack', () => {
|
||||
it('returns [] when compose ps already manages containers', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c1' }]);
|
||||
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
@@ -1710,45 +1738,174 @@ describe('DockerController - getLegacyOrphanContainersByStack', () => {
|
||||
|
||||
it('returns legacy orphan IDs when compose ps is empty', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);
|
||||
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]);
|
||||
fetchSpy.mockRestore();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to legacy orphan lookup when compose ps throws', async () => {
|
||||
it('returns [] when compose ps throws, even if fallback would match containers', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockRejectedValue(new Error('compose ps failed'));
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
|
||||
.mockResolvedValue([{ Id: 'legacy-c2' }]);
|
||||
const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose ps failed'));
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c2' }]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c2' }]);
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
|
||||
// Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
fetchSpy.mockRestore();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns [] and skips fallback when compose ps stdout is unparseable', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({
|
||||
stdout: 'this is not json at all just random text { broken [',
|
||||
stderr: '',
|
||||
});
|
||||
const dc = DockerController.getInstance(1);
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);
|
||||
|
||||
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
fallbackSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - classifyLegacyOrphansForUpdate', () => {
|
||||
it('returns none when compose ps already manages containers', async () => {
|
||||
// Reuse the same mocks as getLegacyOrphanContainersByStack tests in this file.
|
||||
async function classifyDc() {
|
||||
const { default: DockerController } = await import('../services/DockerController');
|
||||
const dc = DockerController.getInstance(1);
|
||||
vi.spyOn(dc as any, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]);
|
||||
return DockerController.getInstance(1);
|
||||
}
|
||||
|
||||
it('returns none when compose ps already manages containers', async () => {
|
||||
const dc = await classifyDc();
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]);
|
||||
await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'none' });
|
||||
});
|
||||
|
||||
it('returns classification_failed when compose ps and fallback both fail', async () => {
|
||||
const { default: DockerController } = await import('../services/DockerController');
|
||||
const dc = DockerController.getInstance(1);
|
||||
vi.spyOn(dc as any, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom'));
|
||||
vi.spyOn(dc as any, 'smartFallback').mockRejectedValue(new Error('fallback boom'));
|
||||
it('returns orphans when compose ps is empty and fallback finds legacy containers', async () => {
|
||||
const dc = await classifyDc();
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
|
||||
spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: 'legacy-c2' }]);
|
||||
await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({
|
||||
status: 'orphans',
|
||||
ids: ['legacy-c1', 'legacy-c2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns classification_failed when compose ps throws, even if fallback would match containers', async () => {
|
||||
const dc = await classifyDc();
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom'));
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c1' }]);
|
||||
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
|
||||
expect(result).toEqual({ status: 'classification_failed', error: 'compose boom' });
|
||||
// Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns classification_failed when compose ps is empty and fallback throws', async () => {
|
||||
const dc = await classifyDc();
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
|
||||
spyOrphanDc(dc, 'smartFallback').mockRejectedValue(new Error('fallback boom'));
|
||||
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
|
||||
expect(result).toEqual({ status: 'classification_failed', error: 'fallback boom' });
|
||||
});
|
||||
|
||||
it('returns classification_failed when compose ps stdout is unparseable', async () => {
|
||||
mockExecFileAsync.mockResolvedValue({
|
||||
stdout: 'this is not json at all just random text { broken [',
|
||||
stderr: '',
|
||||
});
|
||||
const dc = await classifyDc();
|
||||
const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);
|
||||
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
|
||||
expect(result.status).toBe('classification_failed');
|
||||
expect(String((result as { error?: string }).error ?? '')).toMatch(/unparseable JSON/i);
|
||||
expect(fallbackSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - smartFallback stack-dir evidence', () => {
|
||||
const composeYaml = `services:\n web:\n image: nginx:alpine\n`;
|
||||
let stackDir: string;
|
||||
let tmpRoot: string;
|
||||
|
||||
function runningContainer(id: string, name: string, labels: Record<string, string> = {}) {
|
||||
return { Id: id, Names: [name], Labels: labels, State: 'running', Status: 'Up', Ports: [] };
|
||||
}
|
||||
|
||||
async function orphansWithEmptyPs() {
|
||||
const dc = DockerController.getInstance(1);
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
|
||||
return dc.getLegacyOrphanContainersByStack('my-stack');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-orphan-'));
|
||||
composeDirRef.current = tmpRoot;
|
||||
stackDir = path.join(tmpRoot, 'my-stack');
|
||||
await fs.mkdir(stackDir);
|
||||
await fs.writeFile(path.join(stackDir, 'docker-compose.yml'), composeYaml);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
composeDirRef.current = '/test/compose';
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('excludes name-matched containers without stack-dir evidence', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
runningContainer('unrelated-web', '/web'),
|
||||
]);
|
||||
await expect(orphansWithEmptyPs()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('includes name-matched containers whose working_dir equals the stack dir', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
runningContainer('legacy-web', '/web', {
|
||||
'com.docker.compose.project.working_dir': stackDir,
|
||||
}),
|
||||
]);
|
||||
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web' }]);
|
||||
});
|
||||
|
||||
it('matches working_dir case-insensitively on win32', async () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
runningContainer('legacy-web-case', '/web', {
|
||||
'com.docker.compose.project.working_dir': stackDir.toUpperCase(),
|
||||
}),
|
||||
]);
|
||||
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web-case' }]);
|
||||
});
|
||||
|
||||
it('excludes name-matched containers whose working_dir points elsewhere', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
runningContainer('other-stack-web', '/web', {
|
||||
'com.docker.compose.project.working_dir': path.join(tmpRoot, 'other-stack'),
|
||||
}),
|
||||
]);
|
||||
await expect(orphansWithEmptyPs()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('includes name-matched containers whose config_files path is under the stack dir', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
runningContainer('legacy-cfg', '/my-stack-web-1', {
|
||||
'com.docker.compose.project.config_files': path.join(stackDir, 'docker-compose.yml'),
|
||||
}),
|
||||
]);
|
||||
await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-cfg' }]);
|
||||
});
|
||||
|
||||
it('surfaces listContainers failure as classification_failed on update', async () => {
|
||||
mockDocker.listContainers.mockRejectedValue(new Error('daemon unavailable'));
|
||||
|
||||
const { default: DockerController } = await import('../services/DockerController');
|
||||
const dc = DockerController.getInstance(1);
|
||||
spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
|
||||
const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
|
||||
expect(result).toEqual({ status: 'classification_failed', error: 'daemon unavailable' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,10 +43,15 @@ function container(p: Partial<DependencyContainer> & { id: string }): Dependency
|
||||
const findingKinds = (r: { findings: { kind: string }[] }): string[] => r.findings.map((f) => f.kind).sort();
|
||||
|
||||
function effSvc(over: Partial<EffService> = {}): EffService {
|
||||
const hasHealthcheck = over.hasHealthcheck ?? true;
|
||||
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [], ...over,
|
||||
privileged: false, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [],
|
||||
...over,
|
||||
hasHealthcheck,
|
||||
composeHealthcheck,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,15 @@ describe('buildEffectiveServiceModel', () => {
|
||||
expect(result.services[0].hasHealthcheck).toBe(false);
|
||||
});
|
||||
|
||||
it('treats test NONE as a disabled healthcheck', async () => {
|
||||
stubRender(JSON.stringify({
|
||||
services: { web: { image: 'a', healthcheck: { test: ['NONE'] } } },
|
||||
}));
|
||||
const result = await buildEffectiveServiceModel(1, 'mystack');
|
||||
if (!result.renderable) throw new Error('expected renderable');
|
||||
expect(result.services[0].hasHealthcheck).toBe(false);
|
||||
});
|
||||
|
||||
it('parses depends_on given in the short list form', async () => {
|
||||
stubRender(JSON.stringify({
|
||||
services: { web: { image: 'a', depends_on: ['db', 'cache'] } },
|
||||
|
||||
@@ -14,6 +14,7 @@ function svc(overrides: Record<string, unknown>) {
|
||||
networkMode: undefined as string | undefined,
|
||||
restart: undefined as string | undefined,
|
||||
hasHealthcheck: false,
|
||||
composeHealthcheck: 'absent' as const,
|
||||
envKeys: [],
|
||||
networks: [],
|
||||
extraHosts: [],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import {
|
||||
FLEET_UPDATE_CACHE_KEY,
|
||||
invalidateFleetUpdateCache,
|
||||
isFullStackUpdatePath,
|
||||
isUpdatePreviewPath,
|
||||
} from '../helpers/fleetUpdateCache';
|
||||
|
||||
describe('isFullStackUpdatePath', () => {
|
||||
it('matches full-stack update paths after the /api mount strip', () => {
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/update')).toBe(true);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/update?nodeId=2')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects service-scoped update and restore paths', () => {
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/update')).toBe(false);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/services/redis/restore')).toBe(false);
|
||||
expect(isFullStackUpdatePath('/stacks/paperless/deploy')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdatePreviewPath', () => {
|
||||
it('matches update-preview paths after the /api mount strip', () => {
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update-preview')).toBe(true);
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update-preview?nodeId=2')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects full-stack update and other stack paths', () => {
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/update')).toBe(false);
|
||||
expect(isUpdatePreviewPath('/stacks/paperless/services/redis/update')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateFleetUpdateCache', () => {
|
||||
beforeEach(() => {
|
||||
CacheService.getInstance().flush();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CacheService.getInstance().flush();
|
||||
});
|
||||
|
||||
it('drops the shared fleet-updates key', async () => {
|
||||
const cache = CacheService.getInstance();
|
||||
await cache.getOrFetch(FLEET_UPDATE_CACHE_KEY, 60_000, async () => ({ '1': { web: true } }));
|
||||
invalidateFleetUpdateCache();
|
||||
expect(cache.get(FLEET_UPDATE_CACHE_KEY)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,7 @@ function setTracker(over: Partial<import('../services/FleetUpdateTrackerService'
|
||||
previousVersion: '0.83.0',
|
||||
previousProcessStart: 1,
|
||||
wasOffline: false,
|
||||
operationKind: 'update',
|
||||
...over,
|
||||
});
|
||||
}
|
||||
@@ -370,6 +371,7 @@ describe('forced-recheck throttle', () => {
|
||||
FleetUpdateTrackerService.getInstance().set(proxyNodeId, {
|
||||
status: 'failed', startedAt: Date.now(), previousVersion: null,
|
||||
previousProcessStart: null, wasOffline: false, resolvedAt: Date.now(), error: 'boom',
|
||||
operationKind: 'update',
|
||||
});
|
||||
|
||||
const second = await request(app)
|
||||
@@ -413,3 +415,170 @@ describe('GET /api/fleet/update-status/release-notes', () => {
|
||||
expect(res.body.htmlUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compose reapply status and concurrency', () => {
|
||||
it('exposes canReapplyCompose for local when SelfUpdateService is available', async () => {
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'isAvailable').mockReturnValue(true);
|
||||
mockCompareTargetFetch();
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.nodes.find((n: { type: string }) => n.type === 'local');
|
||||
expect(local.canReapplyCompose).toBe(true);
|
||||
});
|
||||
|
||||
it('sets canReapplyCompose false for a remote without self-update capability', async () => {
|
||||
mockMeta(ONLINE({ capabilities: ['stacks'] }));
|
||||
mockCompareTargetFetch();
|
||||
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(200);
|
||||
const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === proxyNodeId);
|
||||
expect(remote.canReapplyCompose).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a reapply tracker via startedAt change without requiring a version bump', async () => {
|
||||
setTracker({
|
||||
operationKind: 'reapply_configuration',
|
||||
previousVersion: '0.83.0',
|
||||
previousProcessStart: 1,
|
||||
startedAt: Date.now() - RECENT_MS,
|
||||
});
|
||||
mockMeta(ONLINE({ version: '0.83.0', startedAt: 2 }));
|
||||
mockCompareTargetFetch();
|
||||
expect(await getStatus()).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not complete a reapply tracker via signal 4 when version is already current', async () => {
|
||||
setTracker({
|
||||
operationKind: 'reapply_configuration',
|
||||
previousVersion: '0.99.0',
|
||||
previousProcessStart: 1,
|
||||
startedAt: Date.now() - 20_000,
|
||||
});
|
||||
// Node already at compare target; signal 4 would false-complete an update,
|
||||
// but must not for reapply while startedAt is unchanged.
|
||||
mockMeta(ONLINE({ version: '0.99.0', startedAt: 1 }));
|
||||
mockCompareTargetFetch();
|
||||
expect(await getStatus()).toBe('updating');
|
||||
});
|
||||
|
||||
it('returns 409 when reapply is requested while an update tracker is in flight', async () => {
|
||||
setTracker({ operationKind: 'update' });
|
||||
mockTarget();
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body?.error).toMatch(/already in progress/i);
|
||||
});
|
||||
|
||||
it('dispatches remote reapply to /api/system/reapply-compose without updateBlocked gating', async () => {
|
||||
mockTarget();
|
||||
mockMeta(ONLINE({ updateBlocked: true, imagePinKind: 'digest', imageChannel: 'community' }));
|
||||
let reapplyUrl: string | null = null;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
reapplyUrl = url;
|
||||
return new Response(JSON.stringify({ message: 'ok' }), { status: 202 });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(reapplyUrl).toContain('/api/system/reapply-compose');
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.operationKind)
|
||||
.toBe('reapply_configuration');
|
||||
});
|
||||
|
||||
it('reserves the tracker before remote dispatch so a concurrent reapply gets 409 without overwriting success', async () => {
|
||||
mockTarget();
|
||||
// Hold meta so the first request sits in the dispatch set before the
|
||||
// pollable tracker exists; the second must still 409 on that lock.
|
||||
let releaseMeta!: (value: RemoteMeta) => void;
|
||||
const metaHeld = new Promise<RemoteMeta>((resolve) => { releaseMeta = resolve; });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async () => metaHeld);
|
||||
|
||||
let releaseRemote!: (value: Response) => void;
|
||||
const remoteHeld = new Promise<Response>((resolve) => { releaseRemote = resolve; });
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (url.includes('/api/system/reapply-compose')) {
|
||||
return remoteHeld;
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
|
||||
// Supertest is lazy until the thenable is consumed; start the request now.
|
||||
const firstPromise = request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth)
|
||||
.then((res) => res);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(NodeRegistry.getInstance().fetchMetaForNode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const secondDuringMeta = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(secondDuringMeta.status).toBe(409);
|
||||
expect(secondDuringMeta.body?.error).toMatch(/already in progress/i);
|
||||
|
||||
releaseMeta(ONLINE());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('updating');
|
||||
});
|
||||
|
||||
const secondDuringPost = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(secondDuringPost.status).toBe(409);
|
||||
|
||||
releaseRemote(new Response(JSON.stringify({ message: 'ok' }), { status: 202 }));
|
||||
const first = await firstPromise;
|
||||
expect(first.status).toBe(202);
|
||||
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
|
||||
expect(tracker?.status).toBe('updating');
|
||||
expect(tracker?.operationKind).toBe('reapply_configuration');
|
||||
expect(tracker?.previousVersion).toBe('0.83.0');
|
||||
expect(tracker?.previousProcessStart).toBe(1);
|
||||
expect(tracker?.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks a reserved remote reapply as failed when the peer rejects, without leaving a false updating row for a second request', async () => {
|
||||
mockTarget();
|
||||
mockMeta(ONLINE());
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
try {
|
||||
if (new URL(url).hostname === 'api.github.com') {
|
||||
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return new Response(JSON.stringify({
|
||||
error: 'An image operation is already in progress.',
|
||||
code: 'IMAGE_OPERATION_IN_FLIGHT',
|
||||
}), { status: 409 });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
|
||||
.set('Authorization', adminAuth);
|
||||
expect(res.status).toBe(502);
|
||||
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
|
||||
expect(tracker?.status).toBe('failed');
|
||||
expect(tracker?.error).toMatch(/already in progress/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ function mk(over: Partial<UpdateTracker>): UpdateTracker {
|
||||
previousVersion: null,
|
||||
previousProcessStart: null,
|
||||
wasOffline: false,
|
||||
operationKind: 'update',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Unit tests for collectServiceHealthcheckEvidence. Mocks Docker list/inspect
|
||||
* and image inspect; asserts structural evidence states and that Test command
|
||||
* text never appears in returned evidence.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { collectServiceHealthcheckEvidence } from '../services/healthcheck/collectServiceHealthcheckEvidence';
|
||||
import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel';
|
||||
import DockerController from '../services/DockerController';
|
||||
|
||||
function svc(over: Partial<EffService> = {}): EffService {
|
||||
const hasHealthcheck = over.hasHealthcheck ?? false;
|
||||
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
|
||||
privileged: false, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [],
|
||||
...over,
|
||||
hasHealthcheck,
|
||||
composeHealthcheck,
|
||||
};
|
||||
}
|
||||
|
||||
function model(services: EffService[]): EffectiveModel {
|
||||
return { projectName: 'proj', services, networks: {}, volumes: {} };
|
||||
}
|
||||
|
||||
function mockDocker(opts: {
|
||||
list?: unknown[];
|
||||
inspectById?: Record<string, { Config?: { Image?: string; Healthcheck?: { Test?: unknown } } }>;
|
||||
image?: { Config?: { Healthcheck?: { Test?: unknown } } } | 'missing' | 'error';
|
||||
}) {
|
||||
const getContainer = vi.fn((id: string) => ({
|
||||
inspect: vi.fn(async () => {
|
||||
const hit = opts.inspectById?.[id];
|
||||
if (!hit) {
|
||||
const err = Object.assign(new Error('not found'), { statusCode: 404 });
|
||||
throw err;
|
||||
}
|
||||
return hit;
|
||||
}),
|
||||
}));
|
||||
const listContainers = vi.fn(async () => opts.list ?? []);
|
||||
const getDocker = vi.fn(() => ({ listContainers, getContainer }));
|
||||
const inspectImage = vi.fn(async () => {
|
||||
if (opts.image === 'missing' || opts.image === 'error') {
|
||||
throw Object.assign(new Error('No such image'), { statusCode: 404 });
|
||||
}
|
||||
return { inspect: opts.image ?? { Config: {} }, history: [] };
|
||||
});
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDocker,
|
||||
inspectImage,
|
||||
} as unknown as DockerController);
|
||||
return { listContainers, getContainer, inspectImage };
|
||||
}
|
||||
|
||||
describe('collectServiceHealthcheckEvidence', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns compose-declared for an active Compose healthcheck without Docker calls', async () => {
|
||||
const { listContainers } = mockDocker({});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ hasHealthcheck: true, composeHealthcheck: 'active' })]), true,
|
||||
);
|
||||
expect(evidence.web).toEqual({ state: 'compose-declared', origin: 'compose', consistentReplicas: null });
|
||||
expect(listContainers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns explicitly-disabled for Compose disablement', async () => {
|
||||
mockDocker({});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'disabled' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('explicitly-disabled');
|
||||
});
|
||||
|
||||
it('lists containers by Compose projectName, not the stack directory name', async () => {
|
||||
const { listContainers } = mockDocker({
|
||||
list: [{
|
||||
Id: 'c1',
|
||||
Labels: { 'com.docker.compose.service': 'web' },
|
||||
Image: 'nginx:1.27',
|
||||
}],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
},
|
||||
});
|
||||
const m: EffectiveModel = {
|
||||
projectName: 'qa-hc-1713',
|
||||
services: [svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })],
|
||||
networks: {},
|
||||
volumes: {},
|
||||
};
|
||||
const evidence = await collectServiceHealthcheckEvidence(1, 'qa-healthcheck', m, true);
|
||||
expect(listContainers).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
filters: { label: ['com.docker.compose.project=qa-hc-1713'] },
|
||||
});
|
||||
expect(evidence.web.state).toBe('runtime-inherited');
|
||||
});
|
||||
|
||||
it('recognizes runtime-inherited healthchecks and never returns Test text', async () => {
|
||||
mockDocker({
|
||||
list: [{
|
||||
Id: 'c1',
|
||||
Names: ['/proj-web-1'],
|
||||
Labels: { 'com.docker.compose.service': 'web' },
|
||||
Image: 'nginx:1.27',
|
||||
}],
|
||||
inspectById: {
|
||||
c1: {
|
||||
Config: {
|
||||
Image: 'nginx:1.27',
|
||||
Healthcheck: { Test: ['CMD-SHELL', 'curl -f http://x/?token=secret-token || exit 1'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web).toEqual({
|
||||
state: 'runtime-inherited',
|
||||
origin: 'runtime',
|
||||
consistentReplicas: true,
|
||||
});
|
||||
expect(JSON.stringify(evidence)).not.toContain('secret-token');
|
||||
expect(JSON.stringify(evidence)).not.toContain('CMD-SHELL');
|
||||
});
|
||||
|
||||
it('matches containers via composeServiceMatch when the service label is absent', async () => {
|
||||
mockDocker({
|
||||
list: [{ Id: 'c1', Names: ['/web'], Image: 'nginx:1.27' }],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
},
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ name: 'web', composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('runtime-inherited');
|
||||
});
|
||||
|
||||
it('ignores stale image replicas and uses local-image evidence', async () => {
|
||||
mockDocker({
|
||||
list: [{
|
||||
Id: 'c1',
|
||||
Labels: { 'com.docker.compose.service': 'web' },
|
||||
Image: 'nginx:old',
|
||||
}],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:old', Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
},
|
||||
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('local-image-inherited');
|
||||
expect(evidence.web.origin).toBe('local-image');
|
||||
});
|
||||
|
||||
it('reports inconsistent-replicas when coverage mixes', async () => {
|
||||
mockDocker({
|
||||
list: [
|
||||
{ Id: 'c1', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
|
||||
{ Id: 'c2', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
|
||||
],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
c2: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['NONE'] } } },
|
||||
},
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('inconsistent-replicas');
|
||||
});
|
||||
|
||||
it('reports unverifiable when Docker is unavailable', async () => {
|
||||
mockDocker({});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent' })]), false,
|
||||
);
|
||||
expect(evidence.web.state).toBe('unverifiable');
|
||||
});
|
||||
|
||||
it('reports unverifiable for a missing local image', async () => {
|
||||
mockDocker({ list: [], image: 'missing' });
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('unverifiable');
|
||||
});
|
||||
|
||||
it('reports unverifiable for build-only services with no image', async () => {
|
||||
mockDocker({ list: [] });
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: undefined })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('unverifiable');
|
||||
});
|
||||
|
||||
it('reports absent when local image has no healthcheck', async () => {
|
||||
mockDocker({ list: [], image: { Config: {} } });
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web).toEqual({
|
||||
state: 'absent',
|
||||
origin: 'local-image',
|
||||
consistentReplicas: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports absent when matching replicas lack a healthcheck even if the local image has one', async () => {
|
||||
mockDocker({
|
||||
list: [{
|
||||
Id: 'c1',
|
||||
Labels: { 'com.docker.compose.service': 'web' },
|
||||
Image: 'nginx:1.27',
|
||||
}],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['NONE'] } } },
|
||||
},
|
||||
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web).toEqual({
|
||||
state: 'absent',
|
||||
origin: 'runtime',
|
||||
consistentReplicas: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unverifiable when some replica inspects fail amid otherwise full coverage', async () => {
|
||||
mockDocker({
|
||||
list: [
|
||||
{ Id: 'c1', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
|
||||
{ Id: 'gone', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' },
|
||||
],
|
||||
inspectById: {
|
||||
c1: { Config: { Image: 'nginx:1.27', Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
},
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('unverifiable');
|
||||
expect(evidence.web.origin).toBe('runtime');
|
||||
});
|
||||
|
||||
it('treats a disappearing container as inspect failure and falls through', async () => {
|
||||
mockDocker({
|
||||
list: [{ Id: 'gone', Labels: { 'com.docker.compose.service': 'web' }, Image: 'nginx:1.27' }],
|
||||
inspectById: {},
|
||||
image: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
});
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('local-image-inherited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectServiceHealthcheckEvidence list failure', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDocker: () => ({
|
||||
listContainers: vi.fn(async () => { throw new Error('daemon down'); }),
|
||||
getContainer: vi.fn(),
|
||||
}),
|
||||
inspectImage: vi.fn(async () => ({
|
||||
inspect: { Config: { Healthcheck: { Test: ['CMD', 'true'] } } },
|
||||
history: [],
|
||||
})),
|
||||
} as unknown as DockerController);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('falls through to local-image evidence when the container list fails', async () => {
|
||||
const evidence = await collectServiceHealthcheckEvidence(
|
||||
1, 'proj', model([svc({ composeHealthcheck: 'absent', image: 'nginx:1.27' })]), true,
|
||||
);
|
||||
expect(evidence.web.state).toBe('local-image-inherited');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyComposeHealthcheck,
|
||||
isComposeHealthcheckActive,
|
||||
isDockerHealthcheckActive,
|
||||
} from '../helpers/healthcheckPresence';
|
||||
|
||||
describe('classifyComposeHealthcheck', () => {
|
||||
it('treats a missing or non-object healthcheck as absent', () => {
|
||||
expect(classifyComposeHealthcheck(undefined)).toBe('absent');
|
||||
expect(classifyComposeHealthcheck(null)).toBe('absent');
|
||||
expect(classifyComposeHealthcheck('CMD')).toBe('absent');
|
||||
});
|
||||
|
||||
it('treats disable: true as disabled', () => {
|
||||
expect(classifyComposeHealthcheck({ disable: true })).toBe('disabled');
|
||||
expect(isComposeHealthcheckActive({ disable: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats test NONE forms as disabled', () => {
|
||||
expect(classifyComposeHealthcheck({ test: 'NONE' })).toBe('disabled');
|
||||
expect(classifyComposeHealthcheck({ test: ['NONE'] })).toBe('disabled');
|
||||
expect(classifyComposeHealthcheck({ test: ['none'] })).toBe('disabled');
|
||||
});
|
||||
|
||||
it('treats an active test as active', () => {
|
||||
expect(classifyComposeHealthcheck({ test: ['CMD', 'true'] })).toBe('active');
|
||||
expect(isComposeHealthcheckActive({ test: ['CMD', 'curl', '-f', 'http://localhost'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats empty or timing-only healthcheck objects as absent, not active', () => {
|
||||
expect(classifyComposeHealthcheck({})).toBe('absent');
|
||||
expect(classifyComposeHealthcheck({ disable: false })).toBe('absent');
|
||||
expect(classifyComposeHealthcheck({ interval: '30s', timeout: '3s' })).toBe('absent');
|
||||
expect(isComposeHealthcheckActive({ disable: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDockerHealthcheckActive', () => {
|
||||
it('is false for missing, empty, and NONE', () => {
|
||||
expect(isDockerHealthcheckActive(undefined)).toBe(false);
|
||||
expect(isDockerHealthcheckActive([])).toBe(false);
|
||||
expect(isDockerHealthcheckActive(['NONE'])).toBe(false);
|
||||
expect(isDockerHealthcheckActive('NONE')).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for a real Test array without exposing its contents', () => {
|
||||
const secretTest = ['CMD-SHELL', 'curl -f http://x/?token=secret-token || exit 1'];
|
||||
expect(isDockerHealthcheckActive(secretTest)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -270,4 +270,46 @@ describe('ImageOperationService', () => {
|
||||
|
||||
expect(changed).not.toBe(baseline);
|
||||
});
|
||||
|
||||
it('executeClaimedComposeReapply transitions to recreating and watches helper exit before trigger', async () => {
|
||||
const callOrder: string[] = [];
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation(() => {
|
||||
callOrder.push('watch');
|
||||
});
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'triggerComposeReapply').mockImplementation(async () => {
|
||||
callOrder.push('trigger');
|
||||
});
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
|
||||
|
||||
const service = ImageOperationService.getInstance();
|
||||
const claim = await service.claimComposeReapply();
|
||||
expect(claim).toEqual({ ok: true });
|
||||
const result = await service.executeClaimedComposeReapply();
|
||||
const current = await service.getCurrentOperation();
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(current?.kind).toBe('compose_reapply');
|
||||
expect(current?.state).toBe('recreating');
|
||||
expect(callOrder).toEqual(['watch', 'trigger']);
|
||||
});
|
||||
|
||||
it('reconcileOnStartup resolves compose_reapply via marker-only success without pin match', async () => {
|
||||
const service = ImageOperationService.getInstance();
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
|
||||
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
|
||||
const claim = await service.claimComposeReapply();
|
||||
expect(claim).toEqual({ ok: true });
|
||||
const current = await service.getCurrentOperation();
|
||||
expect(current?.kind).toBe('compose_reapply');
|
||||
expect(current?.targetImageRef).toBeNull();
|
||||
|
||||
const markerPath = path.join(tmpDir, `image-op-success-${current!.operationId}.json`);
|
||||
await fs.writeFile(markerPath, JSON.stringify({ ok: true, operationId: current!.operationId }), 'utf8');
|
||||
|
||||
await service.reconcileOnStartup();
|
||||
const resolved = await service.getCurrentOperation();
|
||||
expect(resolved?.state).toBe('succeeded');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { detectImageUpdate } from '../services/imageUpdateDetect';
|
||||
import { computeImagePreview } from '../services/UpdatePreviewService';
|
||||
import type { DigestComparisonResult } from '../services/registry-api';
|
||||
|
||||
const PLATFORM = { os: 'linux', architecture: 'amd64' };
|
||||
const LOCAL_DIGEST = `sha256:${'a'.repeat(64)}`;
|
||||
const CREDENTIALS = { username: 'u', password: 'p' };
|
||||
const IMAGE = 'nginx:1.2.3';
|
||||
|
||||
/**
|
||||
* COR-1 regression: persisted sidebar status (via detectImageUpdate /
|
||||
* checkImage) and Fleet/Anatomy preview (computeImagePreview) must agree when
|
||||
* the declared-tag digest matches but a higher semantic tag exists.
|
||||
*/
|
||||
describe('cross-surface update detection (digest match + higher semver)', () => {
|
||||
async function runDetectionAndPreview(
|
||||
comparison: DigestComparisonResult,
|
||||
tags: string[],
|
||||
localDigests: string[] = [LOCAL_DIGEST],
|
||||
) {
|
||||
const compareDigest = vi.fn().mockResolvedValue(comparison);
|
||||
const listRegistryTagsResult = vi.fn().mockResolvedValue({ ok: true, tags });
|
||||
|
||||
const detection = await detectImageUpdate({
|
||||
localDigests,
|
||||
platform: PLATFORM,
|
||||
registry: 'registry-1.docker.io',
|
||||
repo: 'library/nginx',
|
||||
tag: '1.2.3',
|
||||
credentials: CREDENTIALS,
|
||||
deps: { compareDigest, listRegistryTagsResult },
|
||||
});
|
||||
|
||||
const preview = await computeImagePreview('app', IMAGE, {
|
||||
getCredentials: vi.fn().mockResolvedValue(CREDENTIALS),
|
||||
getLocalDigest: vi.fn().mockResolvedValue({ digests: localDigests, platform: PLATFORM, emptyReason: null }),
|
||||
compareDigest,
|
||||
listRegistryTagsResult,
|
||||
});
|
||||
|
||||
return { detection, preview, compareDigest };
|
||||
}
|
||||
|
||||
it('shared detector and preview both report hasUpdate for app:1.2.3 when 1.2.4 exists', async () => {
|
||||
const { detection, preview } = await runDetectionAndPreview(
|
||||
{ kind: 'match' },
|
||||
['1.2.3', '1.2.4'],
|
||||
);
|
||||
|
||||
expect(detection.hasUpdate).toBe(true);
|
||||
expect(detection.nextTag).toBe('1.2.4');
|
||||
expect(detection.digestUpdate).toBe(false);
|
||||
expect(preview.has_update).toBe(true);
|
||||
expect(preview.next_tag).toBe('1.2.4');
|
||||
expect(preview.has_update).toBe(detection.hasUpdate);
|
||||
});
|
||||
|
||||
it('shared detector and preview both clear when digest matches and no higher tag exists', async () => {
|
||||
const { detection, preview } = await runDetectionAndPreview({ kind: 'match' }, ['1.2.3']);
|
||||
|
||||
expect(detection.hasUpdate).toBe(false);
|
||||
expect(preview.has_update).toBe(false);
|
||||
expect(preview.has_update).toBe(detection.hasUpdate);
|
||||
});
|
||||
|
||||
it('shared detector and preview both report hasUpdate when digest errors but 1.2.4 exists', async () => {
|
||||
const { detection, preview } = await runDetectionAndPreview(
|
||||
{ kind: 'error', reason: 'Registry unreachable' },
|
||||
['1.2.3', '1.2.4'],
|
||||
);
|
||||
|
||||
expect(detection.hasUpdate).toBe(true);
|
||||
expect(detection.nextTag).toBe('1.2.4');
|
||||
expect(detection.digestUpdate).toBe(false);
|
||||
expect(detection.digestError).toBe('Registry unreachable');
|
||||
expect(preview.has_update).toBe(true);
|
||||
expect(preview.next_tag).toBe('1.2.4');
|
||||
expect(preview.has_update).toBe(detection.hasUpdate);
|
||||
});
|
||||
|
||||
it('shared detector and preview both report hasUpdate with no local digests when 1.2.4 exists', async () => {
|
||||
const { detection, preview, compareDigest } = await runDetectionAndPreview(
|
||||
{ kind: 'update' },
|
||||
['1.2.3', '1.2.4'],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(compareDigest).not.toHaveBeenCalled();
|
||||
expect(detection.hasUpdate).toBe(true);
|
||||
expect(detection.digestUpdate).toBe(false);
|
||||
expect(detection.nextTag).toBe('1.2.4');
|
||||
expect(preview.has_update).toBe(true);
|
||||
expect(preview.next_tag).toBe('1.2.4');
|
||||
expect(preview.has_update).toBe(detection.hasUpdate);
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import type { StackServiceStatus } from '../services/DatabaseService';
|
||||
|
||||
function ok(hasUpdate: boolean): ImageCheckResult {
|
||||
return { hasUpdate };
|
||||
return { hasUpdate, checkStatus: 'ok', digestUpdate: hasUpdate, tagUpdate: false };
|
||||
}
|
||||
function errored(message: string): ImageCheckResult {
|
||||
return { hasUpdate: false, error: message };
|
||||
return { hasUpdate: false, checkStatus: 'failed', error: message };
|
||||
}
|
||||
function notCheckable(): ImageCheckResult {
|
||||
return { hasUpdate: false, notCheckable: true };
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
describe('reduceServiceStatus', () => {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
const {
|
||||
mockGetAuthForRegistry,
|
||||
mockGetStackUpdateStatus, mockUpsertStackUpdateStatus, mockClearStackUpdateStatus,
|
||||
mockClearAllStackUpdateStatus, mockUpdateGlobalSetting,
|
||||
mockRecordStackCheckFailure, mockGetStackServicesJson,
|
||||
mockGetSystemState, mockSetSystemState, mockAddNotificationHistory,
|
||||
mockDispatchAlert,
|
||||
mockDispatchAlert, mockBroadcastEvent,
|
||||
mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists,
|
||||
mockGetAllContainers, mockGetGlobalSettings, mockInspect,
|
||||
mockBuildEffectiveServiceModel,
|
||||
@@ -20,12 +21,15 @@ const {
|
||||
mockGetStackUpdateStatus: vi.fn().mockReturnValue({}),
|
||||
mockUpsertStackUpdateStatus: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockClearAllStackUpdateStatus: vi.fn().mockReturnValue(0),
|
||||
mockUpdateGlobalSetting: vi.fn(),
|
||||
mockRecordStackCheckFailure: vi.fn(),
|
||||
mockGetStackServicesJson: vi.fn().mockReturnValue([]),
|
||||
mockGetSystemState: vi.fn().mockReturnValue('1'), // default: backfilled
|
||||
mockSetSystemState: vi.fn(),
|
||||
mockAddNotificationHistory: vi.fn(),
|
||||
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
|
||||
mockBroadcastEvent: vi.fn(),
|
||||
mockGetStacks: vi.fn().mockResolvedValue([]),
|
||||
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
||||
mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')),
|
||||
@@ -53,12 +57,14 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getGlobalSettings: mockGetGlobalSettings,
|
||||
updateGlobalSetting: mockUpdateGlobalSetting,
|
||||
getNodes: () => [],
|
||||
getGitSource: () => undefined,
|
||||
getStackProjectEnvFiles: () => [],
|
||||
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
|
||||
getStackUpdateStatus: mockGetStackUpdateStatus,
|
||||
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
||||
clearAllStackUpdateStatus: mockClearAllStackUpdateStatus,
|
||||
recordStackCheckFailure: mockRecordStackCheckFailure,
|
||||
getStackServicesJson: mockGetStackServicesJson,
|
||||
getSystemState: mockGetSystemState,
|
||||
@@ -76,10 +82,15 @@ vi.mock('../services/NotificationService', () => ({
|
||||
NotificationService: {
|
||||
getInstance: () => ({
|
||||
dispatchAlert: mockDispatchAlert,
|
||||
broadcastEvent: mockBroadcastEvent,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/fleetUpdateCache', () => ({
|
||||
invalidateFleetUpdateCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: {
|
||||
getInstance: () => ({
|
||||
@@ -110,11 +121,21 @@ vi.mock('../services/NodeRegistry', () => ({
|
||||
}));
|
||||
|
||||
// compareLocalToRemoteTag is module-scoped inside checkImage; mock it to drive the
|
||||
// comparison outcome while keeping the real parseImageRef / selectLocalRepoDigest.
|
||||
const { mockCompareLocalToRemoteTag } = vi.hoisted(() => ({ mockCompareLocalToRemoteTag: vi.fn() }));
|
||||
// comparison outcome while keeping the real parseImageRef / selectLocalRepoDigests.
|
||||
const { mockCompareLocalToRemoteTag, mockListRegistryTagsResult } = vi.hoisted(() => ({
|
||||
mockCompareLocalToRemoteTag: vi.fn(),
|
||||
// Detection now checks tags alongside digests; default to an empty, successful
|
||||
// list so digest-focused tests are not accidentally driven by a real network
|
||||
// call finding a genuine newer tag for whatever image ref they pass.
|
||||
mockListRegistryTagsResult: vi.fn().mockResolvedValue({ ok: true, tags: [] }),
|
||||
}));
|
||||
vi.mock('../services/registry-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/registry-api')>();
|
||||
return { ...actual, compareLocalToRemoteTag: mockCompareLocalToRemoteTag };
|
||||
return {
|
||||
...actual,
|
||||
compareLocalToRemoteTag: mockCompareLocalToRemoteTag,
|
||||
listRegistryTagsResult: mockListRegistryTagsResult,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Re-export internal helpers via the module ─────────────────────────
|
||||
@@ -158,7 +179,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
||||
it('marks sha256-only refs not-checkable (no tag to track)', async () => {
|
||||
const docker = makeMockDocker();
|
||||
const result = await service.checkImage(docker, 'sha256:abc123');
|
||||
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
|
||||
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
|
||||
});
|
||||
|
||||
it('returns error when local image inspect fails', async () => {
|
||||
@@ -195,7 +216,7 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
||||
// Empty RepoDigests means locally built / not registry-backed.
|
||||
const docker = makeMockDocker([]);
|
||||
const result = await service.checkImage(docker, 'nginx:latest');
|
||||
expect(result).toEqual({ hasUpdate: false, notCheckable: true });
|
||||
expect(result).toEqual({ hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true });
|
||||
});
|
||||
|
||||
it('errors when RepoDigests are present but none resolves a digest', async () => {
|
||||
@@ -207,6 +228,17 @@ describe('ImageUpdateService - image ref parsing (via checkImage)', () => {
|
||||
expect(result.notCheckable).toBeUndefined();
|
||||
expect(result.error).toContain('Could not resolve a local registry digest');
|
||||
});
|
||||
|
||||
it('errors (not a comparison) when the sole valid RepoDigest belongs to an unrelated repository', async () => {
|
||||
// A well-formed digest is present, but it names a different repo (e.g.
|
||||
// left over from a retag): comparing it against this ref's registry state
|
||||
// would risk a false update against unrelated content.
|
||||
const docker = makeMockDocker([`ghcr.io/other/image@sha256:${'b'.repeat(64)}`]);
|
||||
const result = await service.checkImage(docker, 'nginx:latest');
|
||||
expect(result.hasUpdate).toBe(false);
|
||||
expect(result.notCheckable).toBeUndefined();
|
||||
expect(result.error).toContain('Could not resolve a local registry digest');
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkImage surfaces the comparison resolver's outcome ──────────────
|
||||
@@ -218,6 +250,7 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListRegistryTagsResult.mockResolvedValue({ ok: true, tags: [] });
|
||||
(ImageUpdateService as any).instance = undefined;
|
||||
service = ImageUpdateService.getInstance();
|
||||
});
|
||||
@@ -234,29 +267,39 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const dockerWithNginxSemver = (repoDigests: string[] = [`registry-1.docker.io/library/nginx@${LOCAL_DIGEST}`]) => ({
|
||||
getDocker: () => ({
|
||||
getImage: () => ({ inspect: vi.fn().mockResolvedValue({
|
||||
RepoDigests: repoDigests,
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
}) }),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
it('surfaces the specific failure reason (not a generic "unreachable") as the check error', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: false, error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
expect(result).toMatchObject({ hasUpdate: false, checkStatus: 'failed', error: 'Authentication failed for ghcr.io/linuxserver/radarr:latest' });
|
||||
});
|
||||
|
||||
it('reports an update when the comparison resolver classifies the remote as an update', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'update' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: true });
|
||||
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: true, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('reports no update when the comparison resolver classifies the remote as a match', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
const result = await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(result).toEqual({ hasUpdate: false });
|
||||
expect(result).toMatchObject({ hasUpdate: false, digestUpdate: false, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('passes the local digest, platform, and parsed ref through to the comparison resolver', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
await service.checkImage(dockerWithLocalDigest(LOCAL_DIGEST), 'ghcr.io/linuxserver/radarr:latest');
|
||||
expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith(
|
||||
LOCAL_DIGEST,
|
||||
[LOCAL_DIGEST],
|
||||
'ghcr.io',
|
||||
'linuxserver/radarr',
|
||||
'latest',
|
||||
@@ -264,6 +307,50 @@ describe('ImageUpdateService - checkImage surfaces the comparison resolver outco
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an update when the declared tag digest matches but a higher semver tag exists', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
mockListRegistryTagsResult.mockResolvedValue({ ok: true, tags: ['1.2.3', '1.2.4'] });
|
||||
const result = await service.checkImage(dockerWithNginxSemver(), 'nginx:1.2.3');
|
||||
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: false, tagUpdate: true, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('reports an update when digest comparison errors but a higher semver tag exists', async () => {
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' });
|
||||
mockListRegistryTagsResult.mockResolvedValue({ ok: true, tags: ['1.2.3', '1.2.4'] });
|
||||
const result = await service.checkImage(dockerWithNginxSemver(), 'nginx:1.2.3');
|
||||
expect(result).toMatchObject({ hasUpdate: true, digestUpdate: false, tagUpdate: true, checkStatus: 'ok' });
|
||||
});
|
||||
|
||||
it('forwards every matching RepoDigest (stale index ahead of current) to the comparison resolver', async () => {
|
||||
const STALE = `sha256:${'f'.repeat(64)}`;
|
||||
const CURRENT = `sha256:${'e'.repeat(64)}`;
|
||||
const docker = {
|
||||
getDocker: () => ({
|
||||
getImage: () => ({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
RepoDigests: [
|
||||
`redis@${STALE}`,
|
||||
`redis@${CURRENT}`,
|
||||
],
|
||||
Os: 'linux',
|
||||
Architecture: 'amd64',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as any;
|
||||
mockCompareLocalToRemoteTag.mockResolvedValue({ kind: 'match' });
|
||||
const result = await service.checkImage(docker, 'redis:8.8.0');
|
||||
expect(result).toEqual({ hasUpdate: false, digestUpdate: false, tagUpdate: false, checkStatus: 'ok' });
|
||||
expect(mockCompareLocalToRemoteTag).toHaveBeenCalledWith(
|
||||
[STALE, CURRENT],
|
||||
'registry-1.docker.io',
|
||||
'library/redis',
|
||||
'8.8.0',
|
||||
{ os: 'linux', architecture: 'amd64' },
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multi-arch digest comparison persistence (end-to-end via checkNode) ─
|
||||
@@ -288,6 +375,7 @@ services:
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListRegistryTagsResult.mockResolvedValue({ ok: true, tags: [] });
|
||||
(ImageUpdateService as any).instance = undefined;
|
||||
mockGetSystemState.mockReturnValue('1');
|
||||
mockGetStacks.mockResolvedValue(['stackA']);
|
||||
@@ -1132,6 +1220,62 @@ describe('ImageUpdateService - configurable interval & status', () => {
|
||||
service.stop();
|
||||
checkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('start() while checks disabled arms no timer and reports enabled false', () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
|
||||
const service = ImageUpdateService.getInstance();
|
||||
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
|
||||
service.start();
|
||||
const status = service.getStatus();
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.nextCheckAt).toBeNull();
|
||||
expect(status.checking).toBe(false);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(10 * 60 * 1000);
|
||||
expect(checkSpy).not.toHaveBeenCalled();
|
||||
checkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('treats a missing checks-enabled key as enabled', () => {
|
||||
mockGetGlobalSettings.mockReturnValue({});
|
||||
const service = ImageUpdateService.getInstance();
|
||||
expect(ImageUpdateService.isChecksEnabled()).toBe(true);
|
||||
expect(service.getStatus().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('applyChecksEnabled(false) stops polling, clears local findings, and broadcasts invalidate', () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '60' });
|
||||
const service = ImageUpdateService.getInstance();
|
||||
service.start();
|
||||
expect(service.getStatus().nextCheckAt).not.toBeNull();
|
||||
|
||||
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
|
||||
mockUpdateGlobalSetting.mockImplementation((key: string, value: string) => {
|
||||
if (key === 'image_update_checks_enabled') {
|
||||
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: value, image_update_check_interval_minutes: '60' });
|
||||
}
|
||||
});
|
||||
|
||||
const status = service.applyChecksEnabled(false);
|
||||
expect(status.enabled).toBe(false);
|
||||
expect(status.nextCheckAt).toBeNull();
|
||||
expect(mockUpdateGlobalSetting).toHaveBeenCalledWith('image_update_checks_enabled', '0');
|
||||
expect(mockClearAllStackUpdateStatus).toHaveBeenCalledWith(1);
|
||||
expect(mockBroadcastEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: 1,
|
||||
}));
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('triggerManualRefresh returns false when checks are disabled', () => {
|
||||
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0' });
|
||||
const service = ImageUpdateService.getInstance();
|
||||
expect(service.triggerManualRefresh()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stale stack pruning ─────────────────────────────────────────────────
|
||||
@@ -1495,6 +1639,7 @@ services:
|
||||
mockGetSystemState.mockReturnValue('1');
|
||||
mockGetAllContainers.mockResolvedValue([]);
|
||||
mockEnvExists.mockResolvedValue(false);
|
||||
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
|
||||
});
|
||||
|
||||
it('reduces per-service status through the effective model and persists services_json with a generation', async () => {
|
||||
@@ -1616,7 +1761,26 @@ services:
|
||||
});
|
||||
|
||||
describe('recheckStack', () => {
|
||||
it('persists a fresh per-service reduction and returns no warning on success', async () => {
|
||||
it('skips registry probes and DB writes when checks are disabled', async () => {
|
||||
mockGetGlobalSettings.mockReturnValueOnce({ image_update_checks_enabled: '0' });
|
||||
const service = ImageUpdateService.getInstance();
|
||||
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: true });
|
||||
const genBefore = service.peekStackWriteGeneration(1, 'stackA');
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({ outcome: 'cleared', warning: null });
|
||||
expect(service.peekStackWriteGeneration(1, 'stackA')).toBe(genBefore);
|
||||
expect(mockBuildEffectiveServiceModel).not.toHaveBeenCalled();
|
||||
expect(mockGetAllContainers).not.toHaveBeenCalled();
|
||||
expect((service as any).checkImage).not.toHaveBeenCalled();
|
||||
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockRecordStackCheckFailure).not.toHaveBeenCalled();
|
||||
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockClearAllStackUpdateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns still_present when a checkable service still has an update', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
|
||||
renderable: true,
|
||||
services: [specFor('web', 'web:latest'), specFor('worker', 'worker:latest')],
|
||||
@@ -1629,7 +1793,10 @@ services:
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({ warning: null });
|
||||
expect(result).toEqual({
|
||||
outcome: 'still_present',
|
||||
warning: 'The update command completed, but Sencho still detects an available image update.',
|
||||
});
|
||||
expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(
|
||||
1, 'stackA', true, expect.any(Number), 'ok', null,
|
||||
[
|
||||
@@ -1640,13 +1807,103 @@ services:
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a warning and leaves the prior row untouched when the model cannot render', async () => {
|
||||
it('returns cleared when every checkable service is up to date', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
|
||||
renderable: true,
|
||||
services: [specFor('web', 'web:latest')],
|
||||
});
|
||||
mockGetAllContainers.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
|
||||
]);
|
||||
const service = ImageUpdateService.getInstance();
|
||||
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: false });
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({ outcome: 'cleared', warning: null });
|
||||
expect(mockUpsertStackUpdateStatus).toHaveBeenCalledWith(
|
||||
1, 'stackA', false, expect.any(Number), 'ok', null,
|
||||
expect.any(Array),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns verification_failed and leaves the prior row untouched when the model cannot render', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({ renderable: false, code: 'effective_model_render_failed', error: 'no model in test' });
|
||||
const service = ImageUpdateService.getInstance();
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({ warning: 'no model in test' });
|
||||
expect(result).toEqual({ outcome: 'verification_failed', warning: 'no model in test' });
|
||||
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockRecordStackCheckFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns verification_incomplete and preserves prior hasUpdate on a fully failed check', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
|
||||
renderable: true,
|
||||
services: [specFor('web', 'web:latest')],
|
||||
});
|
||||
mockGetAllContainers.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
|
||||
]);
|
||||
mockGetStackServicesJson.mockReturnValueOnce([
|
||||
{ service: 'web', image: 'web:latest', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
]);
|
||||
const service = ImageUpdateService.getInstance();
|
||||
(service as any).checkImage = vi.fn().mockResolvedValue({
|
||||
hasUpdate: false,
|
||||
error: 'registry timeout',
|
||||
});
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'verification_incomplete',
|
||||
warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.',
|
||||
});
|
||||
expect(mockRecordStackCheckFailure).toHaveBeenCalled();
|
||||
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns verification_incomplete when the write lock discards a stale commit', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
|
||||
renderable: true,
|
||||
services: [specFor('web', 'web:latest')],
|
||||
});
|
||||
mockGetAllContainers.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'web:latest', Labels: { 'com.docker.compose.project': 'stackA', 'com.docker.compose.service': 'web' } },
|
||||
]);
|
||||
const service = ImageUpdateService.getInstance();
|
||||
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: false });
|
||||
(service as any).withStackWriteLock = vi.fn().mockResolvedValue(false);
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'verification_incomplete',
|
||||
warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.',
|
||||
});
|
||||
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockRecordStackCheckFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns verification_incomplete when container listing fails', async () => {
|
||||
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
|
||||
renderable: true,
|
||||
services: [specFor('web', 'web:latest')],
|
||||
});
|
||||
mockGetAllContainers.mockRejectedValueOnce(new Error('docker socket down'));
|
||||
const service = ImageUpdateService.getInstance();
|
||||
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: false });
|
||||
|
||||
const result = await service.recheckStack(1, 'stackA');
|
||||
|
||||
expect(result).toEqual({
|
||||
outcome: 'verification_incomplete',
|
||||
warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.',
|
||||
});
|
||||
expect((service as any).checkImage).not.toHaveBeenCalled();
|
||||
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,18 @@ describe('GET /api/image-updates', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeInstanceOf(Object);
|
||||
});
|
||||
|
||||
it('excludes partial and failed retained rows from the confirmed boolean map', async () => {
|
||||
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'ok-stack', true, 1000, 'ok', null);
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'partial-stack', true, 1000, 'partial', 'half');
|
||||
DatabaseService.getInstance().upsertStackUpdateStatus(nodeId, 'failed-stack', true, 1000, 'failed', 'boom');
|
||||
const res = await request(app).get('/api/image-updates').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body['ok-stack']).toBe(true);
|
||||
expect(res.body['partial-stack']).toBe(false);
|
||||
expect(res.body['failed-stack']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/image-updates/detail', () => {
|
||||
@@ -103,6 +115,65 @@ describe('GET /api/image-updates/status', () => {
|
||||
expect(typeof res.body.manualCooldownRemainingMs).toBe('number');
|
||||
expect('lastCheckedAt' in res.body).toBe(true);
|
||||
expect('nextCheckAt' in res.body).toBe(true);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/image-updates/enabled', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).put('/api/image-updates/enabled').send({ enabled: false });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', viewerCookie).send({ enabled: false });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('disables checks, clears local findings, and returns enabled false', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'pending-stack', true, Date.now(), 'ok', null);
|
||||
expect(Object.keys(db.getStackUpdateDetail(nodeId)).length).toBeGreaterThan(0);
|
||||
|
||||
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.nextCheckAt).toBeNull();
|
||||
expect(db.getGlobalSettings().image_update_checks_enabled).toBe('0');
|
||||
expect(db.getStackUpdateDetail(nodeId)).toEqual({});
|
||||
});
|
||||
|
||||
it('re-enables checks and returns enabled true', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
|
||||
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(true);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().image_update_checks_enabled).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/image-updates/refresh when disabled', () => {
|
||||
it('returns 409 with enabled false instead of rate-limit 429', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
|
||||
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
expect(res.body.error).toMatch(/disabled/i);
|
||||
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/image-updates/fleet/refresh when disabled', () => {
|
||||
it('lists the local node in disabled rather than triggered or rateLimited', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
|
||||
const localId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.disabled).toContain(localId);
|
||||
expect(res.body.triggered).not.toContain(localId);
|
||||
expect(res.body.rateLimited).not.toContain(localId);
|
||||
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +412,35 @@ describe('POST /api/auto-update/execute', () => {
|
||||
expect(res.body.error).toMatch(/Missing "target"/);
|
||||
});
|
||||
|
||||
it('rejects an empty targets array with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/non-empty array/);
|
||||
});
|
||||
|
||||
it('rejects invalid names in targets with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: ['ok-stack', '../bad'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid stack name/);
|
||||
});
|
||||
|
||||
it('accepts targets[] and returns a per-stack summary string', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ targets: ['missing-a', 'missing-b'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.result).toBe('string');
|
||||
expect(res.body.result).toMatch(/missing-a/);
|
||||
expect(res.body.result).toMatch(/missing-b/);
|
||||
});
|
||||
|
||||
it('rejects invalid stack name with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
@@ -374,7 +474,7 @@ describe('POST /api/auto-update/execute', () => {
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true } as never);
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const gateSpy = vi.spyOn(PolicyEnforcement, 'enforcePolicyPreDeploy').mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -416,13 +516,22 @@ describe('POST /api/auto-update/execute', () => {
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { HealthGateService } = await import('../services/HealthGateService');
|
||||
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true } as never);
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: true } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-au');
|
||||
const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack')
|
||||
.mockImplementation(async () => {
|
||||
callOrder.push('recheckStack');
|
||||
return { outcome: 'cleared', warning: null } as never;
|
||||
});
|
||||
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockImplementation(() => {
|
||||
callOrder.push('beginStack');
|
||||
return 'gate-au';
|
||||
});
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
@@ -430,12 +539,113 @@ describe('POST /api/auto-update/execute', () => {
|
||||
.send({ target: 'auto-upd-gate' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateSpy).toHaveBeenCalledWith('auto-upd-gate', undefined, true);
|
||||
expect(recheckSpy).toHaveBeenCalledWith(nodeId, 'auto-upd-gate');
|
||||
expect(beginSpy).toHaveBeenCalledWith(nodeId, 'auto-upd-gate', 'update', `auto-update:${TEST_USERNAME}`);
|
||||
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
||||
} finally {
|
||||
containersSpy.mockRestore();
|
||||
checkSpy.mockRestore();
|
||||
updateSpy.mockRestore();
|
||||
recheckSpy.mockRestore();
|
||||
beginSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips Compose apply for tag-only availability without clearing status', async () => {
|
||||
const DockerController = (await import('../services/DockerController')).default;
|
||||
const { ImageUpdateService } = await import('../services/ImageUpdateService');
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.2.3' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: false, tagUpdate: true } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack');
|
||||
const clearSpy = vi.spyOn(DatabaseService.getInstance(), 'clearStackUpdateStatus');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: 'auto-upd-tag-only' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toContain('Compose pin unchanged');
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(recheckSpy).not.toHaveBeenCalled();
|
||||
expect(clearSpy).not.toHaveBeenCalledWith(nodeId, 'auto-upd-tag-only');
|
||||
} finally {
|
||||
containersSpy.mockRestore();
|
||||
checkSpy.mockRestore();
|
||||
updateSpy.mockRestore();
|
||||
recheckSpy.mockRestore();
|
||||
clearSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips digest apply when a sibling image check failed', async () => {
|
||||
const DockerController = (await import('../services/DockerController')).default;
|
||||
const { ImageUpdateService } = await import('../services/ImageUpdateService');
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
{ Id: 'c2', Image: 'redis:latest' },
|
||||
] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValueOnce({ hasUpdate: true, digestUpdate: true, tagUpdate: false } as never)
|
||||
.mockResolvedValueOnce({ hasUpdate: false, error: 'registry timeout', checkStatus: 'failed' } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: 'auto-upd-check-err' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.result).toContain('image check(s) failed');
|
||||
expect(updateSpy).not.toHaveBeenCalled();
|
||||
expect(recheckSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
containersSpy.mockRestore();
|
||||
checkSpy.mockRestore();
|
||||
updateSpy.mockRestore();
|
||||
recheckSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still applies when checkImage reports same-tag digestUpdate', async () => {
|
||||
const DockerController = (await import('../services/DockerController')).default;
|
||||
const { ImageUpdateService } = await import('../services/ImageUpdateService');
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!;
|
||||
|
||||
const containersSpy = vi.spyOn(DockerController.prototype, 'getContainersByStack')
|
||||
.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }] as never);
|
||||
const checkSpy = vi.spyOn(ImageUpdateService.getInstance(), 'checkImage')
|
||||
.mockResolvedValue({ hasUpdate: true, digestUpdate: true, tagUpdate: false } as never);
|
||||
const updateSpy = vi.spyOn(ComposeService.prototype, 'updateStack').mockResolvedValue({ recoveryId: null });
|
||||
const recheckSpy = vi.spyOn(ImageUpdateService.getInstance(), 'recheckStack')
|
||||
.mockResolvedValue({ outcome: 'still_present', warning: null } as never);
|
||||
const clearSpy = vi.spyOn(DatabaseService.getInstance(), 'clearStackUpdateStatus');
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/auto-update/execute')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ target: 'auto-upd-digest' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateSpy).toHaveBeenCalledWith('auto-upd-digest', undefined, true);
|
||||
expect(recheckSpy).toHaveBeenCalledWith(nodeId, 'auto-upd-digest');
|
||||
expect(clearSpy).not.toHaveBeenCalledWith(nodeId, 'auto-upd-digest');
|
||||
} finally {
|
||||
containersSpy.mockRestore();
|
||||
checkSpy.mockRestore();
|
||||
updateSpy.mockRestore();
|
||||
recheckSpy.mockRestore();
|
||||
clearSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ const SCHEMA = `
|
||||
prune_targets TEXT,
|
||||
target_services TEXT,
|
||||
prune_label_filter TEXT,
|
||||
selector_type TEXT,
|
||||
selector_value TEXT,
|
||||
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
||||
);
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('deploy provenance trust boundary', () => {
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(() => resolve()),
|
||||
cookie: vi.fn(),
|
||||
} as unknown as import('express').Response;
|
||||
void Promise.resolve(authMiddleware(req, res, () => {
|
||||
nextCalled = true;
|
||||
|
||||
@@ -506,7 +506,7 @@ describe('Notification suppression - CRUD', () => {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(940005);
|
||||
});
|
||||
|
||||
it('replica does not resurrect a rule after it was deleted, even with a newer updated_at', async () => {
|
||||
it('omitted-body replica DELETE is permanent; delayed POST cannot resurrect', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
@@ -538,9 +538,8 @@ describe('Notification suppression - CRUD', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined();
|
||||
|
||||
// A delayed POST arrives after the DELETE, reordered by the network. Even
|
||||
// though its updated_at is newer than anything the sender ever sent before
|
||||
// the delete, the delete is authoritative: this id must stay gone.
|
||||
// Omitted DELETE body (old hub) fails closed as permanent. A delayed POST
|
||||
// with any updated_at must stay blocked.
|
||||
const delayed = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
@@ -612,21 +611,479 @@ describe('Notification suppression - CRUD', () => {
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.outcome).toBe('applied');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)?.schedule).not.toBeNull();
|
||||
|
||||
// This is the worst case the fix protects: a capability-cleanup DELETE
|
||||
// retracts an all-day/scheduled mute from a node that stopped supporting
|
||||
// it. A delayed re-push of the scheduled rule must not undo that cleanup.
|
||||
// Capability-cleanup DELETE is recoverable at the pushed version. A delayed
|
||||
// re-push at the same or older watermark must not undo cleanup.
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/970008')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 1000 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
|
||||
const delayed = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 2000 }) });
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(delayed.status).toBe(200);
|
||||
expect(delayed.body.outcome).toBe('ignored_recoverable_watermark');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(970008)?.kind).toBe(
|
||||
'recoverable',
|
||||
);
|
||||
});
|
||||
|
||||
it('recoverable soft-cleanup allows recreate when hub re-save is newer', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const replicaRule = (overrides: Record<string, unknown>) => ({
|
||||
id: 980009,
|
||||
name: 'replica-soft-cleanup-resave',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: { days: [2], start_minute: 60, end_minute: 120, tz: 'UTC' },
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Receiver clock skew must not affect ordering. Sign the JWT under the same
|
||||
// mocked clock so exp verification stays valid.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(9_000_000_000_000);
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
try {
|
||||
const first = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.outcome).toBe('applied');
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/980009')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 1000 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined();
|
||||
|
||||
const tie = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(tie.status).toBe(200);
|
||||
expect(tie.body.outcome).toBe('ignored_recoverable_watermark');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined();
|
||||
|
||||
const resave = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ name: 'replica-soft-cleanup-resave-v2', updated_at: 2000 }) });
|
||||
expect(resave.status).toBe(200);
|
||||
expect(resave.body.outcome).toBe('applied');
|
||||
const restored = DatabaseService.getInstance().getNotificationSuppressionRule(980009);
|
||||
expect(restored?.name).toBe('replica-soft-cleanup-resave-v2');
|
||||
expect(restored?.updated_at).toBe(2000);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(980009)).toBeUndefined();
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('permanent DELETE blocks any later POST regardless of updated_at', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/990010')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
|
||||
const post = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 990010,
|
||||
name: 'should-not-return',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
});
|
||||
expect(post.status).toBe(200);
|
||||
expect(post.body.outcome).toBe('ignored_permanent_tombstone');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(990010)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(990010)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('stale recoverable DELETE does not remove a newer stored row', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const replicaRule = (overrides: Record<string, unknown>) => ({
|
||||
id: 991011,
|
||||
name: 'v200',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const post = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 200 }) });
|
||||
expect(post.status).toBe(200);
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/991011')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 100 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('ignored_stale');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(991011)?.updated_at).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(991011)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recoverable DELETE at exact stored version deletes and tombstones', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 992012,
|
||||
name: 'exact-tie-delete',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 150,
|
||||
},
|
||||
});
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/992012')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 150 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(992012)).toBeUndefined();
|
||||
const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(992012);
|
||||
expect(tomb?.kind).toBe('recoverable');
|
||||
expect(tomb?.source_updated_at).toBe(150);
|
||||
});
|
||||
|
||||
it('reordered recoverable tombstones keep the max watermark; permanent wins', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 200 });
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 100 });
|
||||
expect(
|
||||
DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.source_updated_at,
|
||||
).toBe(200);
|
||||
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013);
|
||||
expect(tomb?.kind).toBe('permanent');
|
||||
expect(tomb?.source_updated_at).toBe(200);
|
||||
|
||||
// Later recoverable cannot weaken permanent.
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 999 });
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('partial or invalid DELETE body returns 400 without mutation', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 994014,
|
||||
name: 'keep-me',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const cases: object[] = [
|
||||
{ kind: 'recoverable' },
|
||||
{ source_updated_at: 1 },
|
||||
{ kind: 'nope', source_updated_at: 1 },
|
||||
{ kind: 'recoverable', source_updated_at: 1.5 },
|
||||
{ kind: 'recoverable', source_updated_at: -1 },
|
||||
{ kind: 'recoverable', source_updated_at: '1' },
|
||||
{ kind: 'recoverable', source_updated_at: null },
|
||||
{ kind: 'recoverable', source_updated_at: Number.MAX_SAFE_INTEGER + 1 },
|
||||
];
|
||||
for (const body of cases) {
|
||||
const res = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/994014')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(body);
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(994014)?.name).toBe('keep-me');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(994014)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replica POST rejects missing or invalid created_at and updated_at', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const base = {
|
||||
id: 995015,
|
||||
name: 'bad-ts',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
};
|
||||
|
||||
for (const rule of [
|
||||
{ ...base, updated_at: 1 },
|
||||
{ ...base, created_at: 1 },
|
||||
{ ...base, created_at: -1, updated_at: 1 },
|
||||
{ ...base, created_at: 1, updated_at: 1.5 },
|
||||
{ ...base, created_at: 1, updated_at: '1' },
|
||||
]) {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(995015)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('permanent DELETE removes a newer stored row', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 999019,
|
||||
name: 'newer-row',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/999019')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(999019)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999019)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('empty JSON DELETE body fails closed as permanent', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/999020')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({});
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
expect(
|
||||
DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.source_updated_at,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('one-arg deleteNotificationSuppressionRule defaults to permanent', () => {
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||
id: 996016,
|
||||
name: 'one-arg',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 5,
|
||||
});
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(996016);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(996016)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(996016)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('delete rolls back when tombstone upsert fails', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 997017,
|
||||
name: 'atomic-del',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 7,
|
||||
});
|
||||
const raw = db.getDb();
|
||||
const orig = raw.prepare.bind(raw);
|
||||
const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => {
|
||||
if (
|
||||
typeof sql === 'string' &&
|
||||
sql.includes('INSERT INTO notification_suppression_rule_tombstones')
|
||||
) {
|
||||
throw new Error('forced tombstone upsert failure');
|
||||
}
|
||||
return orig(sql);
|
||||
}) as typeof raw.prepare);
|
||||
|
||||
expect(() =>
|
||||
db.deleteNotificationSuppressionRule(997017, {
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 7,
|
||||
}),
|
||||
).toThrow(/forced tombstone upsert failure/);
|
||||
spy.mockRestore();
|
||||
|
||||
expect(db.getNotificationSuppressionRule(997017)?.name).toBe('atomic-del');
|
||||
expect(db.getNotificationSuppressionRuleTombstone(997017)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recoverable recreate rolls back when insert fails after tombstone clear attempt', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.deleteNotificationSuppressionRule(998018, {
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 10,
|
||||
});
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable');
|
||||
|
||||
const raw = db.getDb();
|
||||
const orig = raw.prepare.bind(raw);
|
||||
const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => {
|
||||
if (
|
||||
typeof sql === 'string' &&
|
||||
sql.includes('INSERT INTO notification_suppression_rules')
|
||||
) {
|
||||
throw new Error('forced replica insert failure');
|
||||
}
|
||||
return orig(sql);
|
||||
}) as typeof raw.prepare);
|
||||
|
||||
expect(() =>
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 998018,
|
||||
name: 'recreate-fail',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 20,
|
||||
}),
|
||||
).toThrow(/forced replica insert failure/);
|
||||
spy.mockRestore();
|
||||
|
||||
expect(db.getNotificationSuppressionRule(998018)).toBeUndefined();
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable');
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.source_updated_at).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE.
|
||||
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE,
|
||||
* durable pending retractions, permanent fan-out to all remotes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
@@ -8,12 +9,18 @@ const mockGetProxyTarget = vi.fn();
|
||||
const mockGetNodes = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockRemoteAdvertises = vi.fn();
|
||||
const mockUpsertPending = vi.fn();
|
||||
const mockDeletePending = vi.fn();
|
||||
const mockListPending = vi.fn();
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
getNode: mockGetNode,
|
||||
upsertNotificationSuppressionPendingRetraction: mockUpsertPending,
|
||||
deleteNotificationSuppressionPendingRetraction: mockDeletePending,
|
||||
listNotificationSuppressionPendingRetractions: mockListPending,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -46,6 +53,7 @@ import {
|
||||
syncSuppressionRuleToFleet,
|
||||
syncSuppressionRuleUpdateToFleet,
|
||||
replicationTargetIds,
|
||||
flushPendingSuppressionRetractions,
|
||||
} from '../helpers/notificationSuppressionSync';
|
||||
import type { NotificationSuppressionRule } from '../services/DatabaseService';
|
||||
|
||||
@@ -72,6 +80,21 @@ function makeRule(overrides: Partial<NotificationSuppressionRule> = {}): Notific
|
||||
const remoteA = { id: 10, name: 'remote-a', type: 'remote' as const };
|
||||
const remoteB = { id: 11, name: 'remote-b', type: 'remote' as const };
|
||||
|
||||
const RETRACTION_CAP = 'notification-suppression-replica-retraction';
|
||||
|
||||
function metaWith(...caps: string[]) {
|
||||
return { capabilities: caps, online: true };
|
||||
}
|
||||
|
||||
function okDeleteResponse(outcome = 'applied') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => ({ success: true, outcome }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationSuppressionSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -83,8 +106,10 @@ describe('notificationSuppressionSync', () => {
|
||||
apiUrl: `http://node-${id}.example:1852`,
|
||||
apiToken: 'tok',
|
||||
}));
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetch.mockResolvedValue(okDeleteResponse());
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP, 'notification-suppression-schedule'));
|
||||
mockListPending.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,6 +125,7 @@ describe('notificationSuppressionSync', () => {
|
||||
syncSuppressionRuleToFleet(makeRule({ schedule: null, node_id: 10 }));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
|
||||
expect(mockFetchMeta).not.toHaveBeenCalled();
|
||||
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.rule.node_id).toBeNull();
|
||||
expect(body.rule.schedule).toBeNull();
|
||||
@@ -121,44 +147,99 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(body.rule.schedule.days).toEqual([6]);
|
||||
});
|
||||
|
||||
it('probe false + DELETE success: no POST; cleanup logged as removed', async () => {
|
||||
it('schedule unsupported + retraction supported: recoverable DELETE, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 555,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(true);
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 555,
|
||||
});
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
expect(mockUpsertPending).not.toHaveBeenCalled();
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(42, 10);
|
||||
});
|
||||
|
||||
it('probe false + no proxy target: no successful-cleanup claim', async () => {
|
||||
it('schedule unsupported + retraction unsupported: no DELETE; queues pending', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue(metaWith('notification-suppression'));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 555,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('queued pending retraction'))).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rule_id: 42,
|
||||
node_id: 10,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 555,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedule unsupported + probe unreachable (offline meta): no DELETE; queues pending', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue({ capabilities: [], online: false });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending.mock.calls[0][0].last_error).toMatch(/unreachable/);
|
||||
});
|
||||
|
||||
it('schedule unsupported + probe unreachable (throw): no DELETE; queues pending', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedule unsupported + no proxy after supported probe: queues pending, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE success, no POST', async () => {
|
||||
it('scheduleInvalid: DELETE success when retraction supported, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
@@ -166,31 +247,44 @@ describe('notificationSuppressionSync', () => {
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
it('scheduleInvalid: opaque DELETE 404 queues pending (not treated as success)', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Not Found',
|
||||
json: async () => {
|
||||
throw new Error('no json');
|
||||
},
|
||||
});
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(mockDeletePending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => {
|
||||
it('scheduleInvalid: DELETE failure queues pending, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'down',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
@@ -199,28 +293,32 @@ describe('notificationSuppressionSync', () => {
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalled();
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: no proxy target logs pending, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
it('scheduleInvalid without retraction capability: no DELETE; queues pending', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 777,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 777,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
|
||||
it('unscheduled-to-scheduled on unsupported schedule target attempts recoverable path', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ node_id: 10, schedule: null });
|
||||
const updated = makeRule({
|
||||
node_id: 10,
|
||||
@@ -231,22 +329,6 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('probe false + DELETE failure: no POST; logs cleanup pending', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('rule 42'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-to-unscheduled POST refresh does not require capability', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
const previous = makeRule({
|
||||
@@ -261,7 +343,8 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(body.rule.schedule).toBeNull();
|
||||
});
|
||||
|
||||
it('stale targets receive DELETE on scope change', async () => {
|
||||
it('stale targets receive recoverable DELETE when retraction supported', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ node_id: null, schedule: null });
|
||||
const updated = makeRule({ node_id: 10, schedule: null });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
@@ -272,4 +355,147 @@ describe('notificationSuppressionSync', () => {
|
||||
const posts = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'POST');
|
||||
expect(posts.some((c) => String(c[0]).includes('node-10'))).toBe(true);
|
||||
});
|
||||
|
||||
it('stale-target without retraction capability: no DELETE; queues pending', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
|
||||
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
|
||||
expect(deletes).toHaveLength(0);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rule_id: 42,
|
||||
node_id: 11,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 99,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stale-target DELETE sends recoverable watermark from updated rule', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
|
||||
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
|
||||
const stale = deletes.find((c) => String(c[0]).includes('node-11'));
|
||||
expect(stale).toBeTruthy();
|
||||
expect(JSON.parse((stale![1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it('authoritative fleet delete fans permanent retraction to all remotes', async () => {
|
||||
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
|
||||
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
|
||||
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBe(2));
|
||||
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
const [, init] = call as [string, { method: string; body: string }];
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(JSON.parse(init.body)).toEqual({ kind: 'permanent', source_updated_at: 321 });
|
||||
}
|
||||
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-10'))).toBe(true);
|
||||
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-11'))).toBe(true);
|
||||
});
|
||||
|
||||
it('authoritative delete transport failure queues pending permanent row', async () => {
|
||||
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'down',
|
||||
json: async () => ({}),
|
||||
});
|
||||
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'permanent',
|
||||
source_updated_at: 321,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignored_stale DELETE keeps pending and does not clear', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => ({ success: true, outcome: 'ignored_stale' }),
|
||||
});
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockDeletePending).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
last_error: expect.stringMatching(/ignored_stale/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('flushPendingSuppressionRetractions retries recoverable only when supported', async () => {
|
||||
mockListPending.mockReturnValue([
|
||||
{
|
||||
rule_id: 7,
|
||||
node_id: 10,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 50,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
attempts: 1,
|
||||
last_error: 'earlier',
|
||||
},
|
||||
]);
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
await flushPendingSuppressionRetractions(10);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending).toHaveBeenCalled();
|
||||
|
||||
mockUpsertPending.mockClear();
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
await flushPendingSuppressionRetractions(10);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 50,
|
||||
});
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(7, 10);
|
||||
});
|
||||
|
||||
it('flushPendingSuppressionRetractions sends permanent without retraction capability', async () => {
|
||||
mockListPending.mockReturnValue([
|
||||
{
|
||||
rule_id: 8,
|
||||
node_id: 11,
|
||||
kind: 'permanent',
|
||||
source_updated_at: 90,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
attempts: 2,
|
||||
last_error: 'offline',
|
||||
},
|
||||
]);
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
await flushPendingSuppressionRetractions(11);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'permanent',
|
||||
source_updated_at: 90,
|
||||
});
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(8, 11);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Additive kind + source_updated_at on notification_suppression_rule_tombstones.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
const existing = holder.instance;
|
||||
if (existing) {
|
||||
try {
|
||||
existing.getDb().close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
holder.instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('notification suppression tombstone migration', () => {
|
||||
let scratchDir: string | null = null;
|
||||
let prevDataDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
resetDatabaseSingleton();
|
||||
if (prevDataDir === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = prevDataDir;
|
||||
}
|
||||
if (scratchDir) {
|
||||
try {
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
scratchDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds kind and source_updated_at; legacy rows stay permanent', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-tomb-mig-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE notification_suppression_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NULL,
|
||||
stack_patterns TEXT NOT NULL,
|
||||
label_ids TEXT NULL,
|
||||
categories TEXT NULL,
|
||||
levels TEXT NULL,
|
||||
applies_to TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at INTEGER NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE notification_suppression_rule_tombstones (
|
||||
id INTEGER PRIMARY KEY,
|
||||
deleted_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_suppression_rule_tombstones (id, deleted_at)
|
||||
VALUES (42, 1700000000000);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_rule_tombstones)').all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toContain('kind');
|
||||
expect(names).toContain('source_updated_at');
|
||||
|
||||
const tomb = db.getNotificationSuppressionRuleTombstone(42);
|
||||
expect(tomb?.kind).toBe('permanent');
|
||||
expect(tomb?.source_updated_at).toBe(1700000000000);
|
||||
|
||||
// Legacy permanent cannot be cleared by a newer hub POST.
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 42,
|
||||
name: 'should-stay-gone',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 9_999_999_999_999,
|
||||
});
|
||||
expect(db.getNotificationSuppressionRule(42)).toBeUndefined();
|
||||
expect(db.getNotificationSuppressionRuleTombstone(42)?.kind).toBe('permanent');
|
||||
});
|
||||
|
||||
it('creates pending retractions table and merges permanent over recoverable', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-pending-'));
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_pending_retractions)').all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
expect(cols.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'rule_id',
|
||||
'node_id',
|
||||
'kind',
|
||||
'source_updated_at',
|
||||
'attempts',
|
||||
'last_error',
|
||||
]),
|
||||
);
|
||||
|
||||
db.upsertNotificationSuppressionPendingRetraction({
|
||||
rule_id: 7,
|
||||
node_id: 3,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 100,
|
||||
last_error: 'unsupported',
|
||||
});
|
||||
db.upsertNotificationSuppressionPendingRetraction({
|
||||
rule_id: 7,
|
||||
node_id: 3,
|
||||
kind: 'permanent',
|
||||
source_updated_at: 50,
|
||||
last_error: 'offline',
|
||||
});
|
||||
|
||||
const rows = db.listNotificationSuppressionPendingRetractions(3);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].kind).toBe('permanent');
|
||||
expect(rows[0].source_updated_at).toBe(100);
|
||||
expect(rows[0].attempts).toBe(2);
|
||||
expect(rows[0].last_error).toBe('offline');
|
||||
|
||||
db.deleteNotificationSuppressionPendingRetraction(7, 3);
|
||||
expect(db.listNotificationSuppressionPendingRetractions(3)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,24 @@ describe('parseEffectiveModel', () => {
|
||||
it('treats a disabled healthcheck as none', () => {
|
||||
const m = parseEffectiveModel({ services: { web: { healthcheck: { disable: true } } } }, 'fallback');
|
||||
expect(m.services[0].hasHealthcheck).toBe(false);
|
||||
expect(m.services[0].composeHealthcheck).toBe('disabled');
|
||||
});
|
||||
|
||||
it('treats test NONE as a disabled healthcheck', () => {
|
||||
const m = parseEffectiveModel({ services: { web: { healthcheck: { test: ['NONE'] } } } }, 'fallback');
|
||||
expect(m.services[0].hasHealthcheck).toBe(false);
|
||||
expect(m.services[0].composeHealthcheck).toBe('disabled');
|
||||
});
|
||||
|
||||
it('treats empty or disable:false healthcheck objects as absent', () => {
|
||||
expect(parseEffectiveModel({ services: { web: { healthcheck: {} } } }, 'fallback').services[0]).toMatchObject({
|
||||
hasHealthcheck: false,
|
||||
composeHealthcheck: 'absent',
|
||||
});
|
||||
expect(parseEffectiveModel({ services: { web: { healthcheck: { disable: false } } } }, 'fallback').services[0]).toMatchObject({
|
||||
hasHealthcheck: false,
|
||||
composeHealthcheck: 'absent',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the provided project name and yields an empty model for garbage', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { isPreflightNoteFinding } from '../services/preflight/rules';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -111,7 +112,10 @@ describe('preflight acknowledgement routes', () => {
|
||||
const acked = get.body.findings.find((f: { ruleId: string; service?: string }) =>
|
||||
f.ruleId === target.ruleId && f.service === target.service);
|
||||
expect(acked?.acknowledged).toBe(true);
|
||||
expect(get.body.activeCount).toBe(get.body.findings.length - get.body.acknowledgedCount);
|
||||
expect(get.body.activeCount).toBe(
|
||||
get.body.findings.filter((f: { ruleId: string; acknowledged?: boolean }) =>
|
||||
!f.acknowledged && !isPreflightNoteFinding(f.ruleId)).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('DELETE clears an acknowledgement', async () => {
|
||||
@@ -140,4 +144,14 @@ describe('preflight acknowledgement routes', () => {
|
||||
.send({ ruleId: 'port-exposed-all-interfaces', expiryMode: 'until_image_change' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects acknowledging informational note findings', async () => {
|
||||
await request(app).post(`/api/stacks/${STACK}/preflight/run`).set('Authorization', authHeader);
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/preflight/acknowledgements`)
|
||||
.set('Authorization', authHeader)
|
||||
.send({ ruleId: 'healthcheck-inherited', service: 'web', expiryMode: 'forever' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/notes cannot be acknowledged/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,15 @@ import type { EffService, EffectiveModel } from '../services/preflight/effective
|
||||
import type { PreflightContext, PreflightFinding } from '../services/preflight/types';
|
||||
|
||||
function svc(over: Partial<EffService> = {}): EffService {
|
||||
const hasHealthcheck = over.hasHealthcheck ?? true;
|
||||
const composeHealthcheck = over.composeHealthcheck ?? (hasHealthcheck ? 'active' : 'absent');
|
||||
return {
|
||||
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
|
||||
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [], ...over,
|
||||
privileged: false, restart: 'unless-stopped', envKeys: [],
|
||||
networks: [], extraHosts: [], labelKeys: [],
|
||||
...over,
|
||||
hasHealthcheck,
|
||||
composeHealthcheck,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +38,9 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
|
||||
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
|
||||
exposureAvailable: true,
|
||||
isSelfStack: false, ...over,
|
||||
isSelfStack: false,
|
||||
healthchecks: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,10 +224,56 @@ describe('hygiene rules', () => {
|
||||
expect(restartFindings[0].remediation).toMatch(/one-shot|init jobs/i);
|
||||
expect(restartFindings[0].remediation).toMatch(/restart: "no"/);
|
||||
expect(restartFindings[0].remediation).toMatch(/unless-stopped/);
|
||||
expect(ids(runRules(ctx({ model: bare })), 'no-healthcheck')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'absent', origin: 'local-image', consistentReplicas: null } },
|
||||
})), 'no-healthcheck')).toHaveLength(1);
|
||||
const withDeployRestart = model([svc({ restart: undefined, deploy: { restart_policy: { condition: 'any' } }})]);
|
||||
expect(ids(runRules(ctx({ model: withDeployRestart })), 'no-restart-policy')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits the healthcheck evidence rule family', () => {
|
||||
const bare = model([svc({ hasHealthcheck: false })]);
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'explicitly-disabled', origin: 'compose', consistentReplicas: null } },
|
||||
})), 'healthcheck-disabled')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
|
||||
})), 'healthcheck-inherited')[0]).toMatchObject({
|
||||
severity: 'info',
|
||||
title: 'Healthcheck inherited from image',
|
||||
});
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'local-image-inherited', origin: 'local-image', consistentReplicas: null } },
|
||||
})), 'healthcheck-inherited')[0].remediation).toMatch(/Optionally declare/);
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'unverifiable', origin: 'none', consistentReplicas: null } },
|
||||
})), 'healthcheck-unverifiable')[0].severity).toBe('info');
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'inconsistent-replicas', origin: 'runtime', consistentReplicas: false } },
|
||||
})), 'healthcheck-inconsistent')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'compose-declared', origin: 'compose', consistentReplicas: null } },
|
||||
})), 'no-healthcheck')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('never embeds healthcheck Test command text in findings', () => {
|
||||
const bare = model([svc({ hasHealthcheck: false })]);
|
||||
const findings = runRules(ctx({
|
||||
model: bare,
|
||||
healthchecks: { web: { state: 'runtime-inherited', origin: 'runtime', consistentReplicas: true } },
|
||||
}));
|
||||
const blob = findings.map(f => `${f.title}\n${f.message}\n${f.remediation ?? ''}`).join('\n');
|
||||
expect(blob).not.toMatch(/\bCMD\b/);
|
||||
expect(blob).not.toMatch(/CMD-SHELL/);
|
||||
expect(blob).not.toContain('secret-token');
|
||||
});
|
||||
it('flags swarm-only deploy fields but not honored ones', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc({ deploy: { placement: {} }})]) })), 'deploy-swarm-only')).toHaveLength(1);
|
||||
expect(ids(runRules(ctx({ model: model([svc({ deploy: { replicas: 3 }})]) })), 'deploy-swarm-only')).toHaveLength(0);
|
||||
@@ -491,7 +544,9 @@ describe('rule registry completeness', () => {
|
||||
const EXPECTED_RULE_IDS = [
|
||||
'render-failed', 'env-unset', 'env-literal-dollar', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
|
||||
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
|
||||
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck',
|
||||
'healthcheck-disabled', 'healthcheck-inherited', 'healthcheck-unverifiable', 'healthcheck-inconsistent',
|
||||
'deploy-swarm-only',
|
||||
'node-state-unavailable',
|
||||
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume',
|
||||
'container-name-internal-dup', 'container-name-collision',
|
||||
|
||||
@@ -15,6 +15,7 @@ let resetPassword: typeof import('../cli/resetPassword').resetPassword;
|
||||
let createEmergencyAdmin: typeof import('../cli/createEmergencyAdmin').createEmergencyAdmin;
|
||||
let clearSessions: typeof import('../cli/clearSessions').clearSessions;
|
||||
let disableSso: typeof import('../cli/disableSso').disableSso;
|
||||
let enableLocalLogin: typeof import('../cli/enableLocalLogin').enableLocalLogin;
|
||||
let validateDb: typeof import('../cli/validateDb').validateDb;
|
||||
let backupData: typeof import('../cli/backupData').backupData;
|
||||
|
||||
@@ -25,6 +26,7 @@ beforeAll(async () => {
|
||||
({ createEmergencyAdmin } = await import('../cli/createEmergencyAdmin'));
|
||||
({ clearSessions } = await import('../cli/clearSessions'));
|
||||
({ disableSso } = await import('../cli/disableSso'));
|
||||
({ enableLocalLogin } = await import('../cli/enableLocalLogin'));
|
||||
({ validateDb } = await import('../cli/validateDb'));
|
||||
({ backupData } = await import('../cli/backupData'));
|
||||
});
|
||||
@@ -126,6 +128,59 @@ describe('disableSso', () => {
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects disabling the last provider while sso_only', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
||||
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
|
||||
for (const cfg of db.getEnabledSSOConfigs()) {
|
||||
if (cfg.provider !== 'oidc_custom') {
|
||||
db.upsertSSOConfig(cfg.provider, false, cfg.config_json);
|
||||
}
|
||||
}
|
||||
const result = disableSso('oidc_custom');
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toMatch(/last SSO provider/i);
|
||||
expect(db.getEnabledSSOConfigs()).toHaveLength(1);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
||||
});
|
||||
|
||||
it('preserves sso_only when disabling one of several providers', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
||||
for (const cfg of db.getEnabledSSOConfigs()) {
|
||||
db.upsertSSOConfig(cfg.provider, false, cfg.config_json);
|
||||
}
|
||||
db.upsertSSOConfig('oidc_google', true, '{"clientId":"g"}');
|
||||
db.upsertSSOConfig('oidc_github', true, '{"clientId":"h"}');
|
||||
const result = disableSso('oidc_google');
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toMatch(/remains SSO only/i);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('sso_only');
|
||||
const remaining = db.getEnabledSSOConfigs().map(c => c.provider).sort();
|
||||
expect(remaining).toEqual(['oidc_github']);
|
||||
});
|
||||
|
||||
it('restores local_and_sso before disabling all providers under sso_only', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
||||
db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}');
|
||||
const result = disableSso();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
||||
expect(db.getEnabledSSOConfigs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableLocalLogin', () => {
|
||||
it('sets authentication_mode to local_and_sso', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('authentication_mode', 'sso_only');
|
||||
const result = enableLocalLogin();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toMatch(/Restart Sencho/i);
|
||||
expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupData', () => {
|
||||
|
||||
@@ -58,9 +58,11 @@ import {
|
||||
getRemoteDigest,
|
||||
getRemoteDigestResult,
|
||||
getAuthToken,
|
||||
listRegistryTags,
|
||||
listRegistryTagsResult,
|
||||
parseImageRef,
|
||||
selectLocalRepoDigest,
|
||||
selectLocalRepoDigests,
|
||||
compareLocalToRemoteTag,
|
||||
MANIFEST_CLASSIFICATION_CACHE_TTL_MS,
|
||||
MANIFEST_INDEX_DESCRIPTOR_CAP,
|
||||
@@ -113,6 +115,34 @@ describe('repoDigestMatchesRef', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseImageRef', () => {
|
||||
// docker.io / index.docker.io / registry-1.docker.io are the same registry, but only
|
||||
// the literal 'registry-1.docker.io' is recognized elsewhere (getAuthToken, the
|
||||
// library/ auto-prefix in parseImageRef). An unnormalized 'docker.io' or 'index.docker.io'
|
||||
// leaks through into request URLs and hits the marketing domain instead of the registry API.
|
||||
// Each alias is listed with and without an explicit library/ namespace to pin both paths.
|
||||
it.each([
|
||||
'docker.io/library/traefik:latest',
|
||||
'docker.io/traefik:latest',
|
||||
'index.docker.io/library/traefik:latest',
|
||||
'index.docker.io/traefik:latest',
|
||||
])('normalizes %s to the registry API host and the library/ namespace', (ref) => {
|
||||
expect(parseImageRef(ref)).toEqual({
|
||||
registry: 'registry-1.docker.io',
|
||||
repo: 'library/traefik',
|
||||
tag: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a bare official image name unchanged (regression guard)', () => {
|
||||
expect(parseImageRef('traefik:latest')).toEqual({
|
||||
registry: 'registry-1.docker.io',
|
||||
repo: 'library/traefik',
|
||||
tag: 'latest',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteDigest HEAD-first lookup', () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
@@ -358,6 +388,31 @@ describe('listRegistryTagsResult', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRegistryTags (compatibility wrapper)', () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
it('lists tags for a public Docker Hub repository with no credentials configured', async () => {
|
||||
route = (url) => tokenOk(url) ?? (
|
||||
url.includes('/tags/list')
|
||||
? { statusCode: 200, headers: {}, body: JSON.stringify({ tags: ['8.7.0', '8.8.0'] }) }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
await expect(listRegistryTags('registry-1.docker.io', 'library/redis', null)).resolves.toEqual(['8.7.0', '8.8.0']);
|
||||
});
|
||||
|
||||
it('still returns empty for a registry that actually requires credentials the caller does not have', async () => {
|
||||
const CHALLENGE = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"';
|
||||
route = (url): FakeResp => {
|
||||
if (url === 'https://ghcr.io/v2/') return { statusCode: 401, headers: { 'www-authenticate': CHALLENGE } };
|
||||
if (url.startsWith('https://ghcr.io/token')) return { statusCode: 401, headers: {} };
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
await expect(listRegistryTags('ghcr.io', 'private/app', undefined)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── selectLocalRepoDigest ───────────────────────────────────────────────
|
||||
|
||||
describe('selectLocalRepoDigest', () => {
|
||||
@@ -374,9 +429,9 @@ describe('selectLocalRepoDigest', () => {
|
||||
expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A);
|
||||
});
|
||||
|
||||
it('falls back to the sole valid entry when nothing matches the ref', () => {
|
||||
it('returns null when the sole valid entry belongs to an unrelated repository, rather than guessing', () => {
|
||||
const repoDigests = [`ghcr.io/other/image@${DIGEST_A}`];
|
||||
expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBe(DIGEST_A);
|
||||
expect(selectLocalRepoDigest(repoDigests, parsed('nginx:latest'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when multiple valid entries exist and none matches the ref', () => {
|
||||
@@ -407,6 +462,64 @@ describe('selectLocalRepoDigest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── selectLocalRepoDigests ──────────────────────────────────────────────
|
||||
|
||||
describe('selectLocalRepoDigests', () => {
|
||||
const parsed = (ref: string) => {
|
||||
const p = parseImageRef(ref);
|
||||
if (!p) throw new Error(`unparseable ${ref}`);
|
||||
return p;
|
||||
};
|
||||
const DIGEST_A = `sha256:${'a'.repeat(64)}`;
|
||||
const DIGEST_B = `sha256:${'b'.repeat(64)}`;
|
||||
const DIGEST_C = `sha256:${'c'.repeat(64)}`;
|
||||
|
||||
it('returns every matching entry in first-seen order', () => {
|
||||
const repoDigests = [`redis@${DIGEST_A}`, `nginx@${DIGEST_B}`, `redis@${DIGEST_C}`];
|
||||
expect(selectLocalRepoDigests(repoDigests, parsed('redis:8.8.0'))).toEqual([DIGEST_A, DIGEST_C]);
|
||||
});
|
||||
|
||||
it('deduplicates matching digests while preserving first-seen order', () => {
|
||||
const repoDigests = [`redis@${DIGEST_A}`, `redis@${DIGEST_B}`, `redis@${DIGEST_A}`];
|
||||
expect(selectLocalRepoDigests(repoDigests, parsed('redis:latest'))).toEqual([DIGEST_A, DIGEST_B]);
|
||||
});
|
||||
|
||||
it('deduplicates case-insensitively on hex digits', () => {
|
||||
const upper = `sha256:${'A'.repeat(64)}`;
|
||||
const lower = `sha256:${'a'.repeat(64)}`;
|
||||
expect(selectLocalRepoDigests([`nginx@${upper}`, `nginx@${lower}`], parsed('nginx:latest'))).toEqual([upper]);
|
||||
});
|
||||
|
||||
it('filters malformed entries and keeps valid matches', () => {
|
||||
const repoDigests = ['redis@sha256:tooshort', `redis@${DIGEST_A}`, 'redis:latest', `redis@${DIGEST_B}`];
|
||||
expect(selectLocalRepoDigests(repoDigests, parsed('redis:latest'))).toEqual([DIGEST_A, DIGEST_B]);
|
||||
});
|
||||
|
||||
it('returns empty when the sole valid entry belongs to an unrelated repository, rather than guessing', () => {
|
||||
// A legitimate retag can leave a lone RepoDigest from a different repo, but
|
||||
// comparing it against this ref's registry state risks a false update
|
||||
// against a registry that has nothing to do with the declared image.
|
||||
expect(selectLocalRepoDigests([`ghcr.io/other/image@${DIGEST_A}`], parsed('nginx:latest'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty when multiple valid entries exist and none matches the ref', () => {
|
||||
expect(selectLocalRepoDigests([`redis@${DIGEST_A}`, `postgres@${DIGEST_B}`], parsed('nginx:latest'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for an empty list', () => {
|
||||
expect(selectLocalRepoDigests([], parsed('nginx:latest'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns every matching RepoDigest for the image ref', () => {
|
||||
const digests = selectLocalRepoDigests([
|
||||
`nginx@${DIGEST_A}`,
|
||||
`nginx@${DIGEST_B}`,
|
||||
`redis@sha256:${'c'.repeat(64)}`,
|
||||
], parsed('nginx:latest'));
|
||||
expect(digests).toEqual([DIGEST_A, DIGEST_B]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── compareLocalToRemoteTag ─────────────────────────────────────────────
|
||||
//
|
||||
// Reproduces and fixes the false-positive multi-arch update: a local
|
||||
@@ -499,7 +612,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag(INDEX_DIGEST, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
|
||||
});
|
||||
@@ -516,7 +629,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
|
||||
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
|
||||
@@ -524,6 +637,215 @@ describe('compareLocalToRemoteTag', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #1684: Docker can list a stale index digest ahead of the current one.
|
||||
const STALE_INDEX = `sha256:${'f'.repeat(64)}`;
|
||||
|
||||
it('matches when a later candidate equals the primary even if the first candidate is a stale index', async () => {
|
||||
route = (url, method) => tokenOk(url) ?? (
|
||||
method === 'HEAD'
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX, INDEX_DIGEST], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches when the current primary is first among multiple candidates', async () => {
|
||||
route = (url, method) => tokenOk(url) ?? (
|
||||
method === 'HEAD'
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag([INDEX_DIGEST, STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
it('expands the index once when a later candidate matches a platform child', async () => {
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX, CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
|
||||
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
|
||||
{ url: manifestDigestUrl(INDEX_DIGEST), method: 'GET' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports update when every candidate is stale after a complete index classification', async () => {
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'update' });
|
||||
});
|
||||
|
||||
it('errors (not update) when the remote index has no descriptor at all for the local platform', async () => {
|
||||
// STANDARD_INDEX_BODY only carries linux/amd64 and linux/arm64 children.
|
||||
// A windows/amd64 node cannot pull anything from this index; that is not
|
||||
// the same fact as "a newer build is available".
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, { os: 'windows', architecture: 'amd64' });
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no windows/amd64 variant') });
|
||||
});
|
||||
|
||||
it('errors (not update) for an empty index with no manifests, rather than a speculative update', async () => {
|
||||
const emptyIndexBody = indexBody([]);
|
||||
const emptyIndexDigest = contentDigest(emptyIndexBody);
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': emptyIndexDigest, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(emptyIndexDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': emptyIndexDigest }, body: emptyIndexBody };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
it('errors (not update) for an index whose only entries are filtered out (attestation-only), not just a literally empty one', async () => {
|
||||
// Exercises the filtering path (parseIndexBody's attestation-manifest and
|
||||
// unknown/unknown continues), distinct from manifests:[] never entering
|
||||
// the per-entry loop at all.
|
||||
const filteredIndexBody = indexBody([
|
||||
{
|
||||
digest: CHILD_AMD64, os: 'unknown', architecture: 'unknown',
|
||||
annotations: { 'vnd.docker.reference.type': 'attestation-manifest', 'vnd.docker.reference.digest': CHILD_ARM64 },
|
||||
},
|
||||
]);
|
||||
const filteredIndexDigest = contentDigest(filteredIndexBody);
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': filteredIndexDigest, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(filteredIndexDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': filteredIndexDigest }, body: filteredIndexBody };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no linux/amd64 variant') });
|
||||
});
|
||||
|
||||
it('matches (not errors) a platform-less runnable descriptor even though no platform-labeled descriptor exists', async () => {
|
||||
// OCI allows a runnable descriptor to omit platform; parseIndexBody routes
|
||||
// it to exactDigests. The index has real, pullable content, so this must
|
||||
// not be confused with the "nothing to pull" case.
|
||||
const platformlessDigest = `sha256:${'7'.repeat(64)}`;
|
||||
const noPlatformBody = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: INDEX_CONTENT_TYPE,
|
||||
manifests: [{ digest: platformlessDigest, mediaType: 'application/vnd.oci.image.manifest.v1+json' }],
|
||||
});
|
||||
const noPlatformDigest = contentDigest(noPlatformBody);
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': noPlatformDigest, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(noPlatformDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': noPlatformDigest }, body: noPlatformBody };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag([platformlessDigest], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
it('errors (not update) for a mixed index: a platform-less leaf cannot be attributed when another descriptor is explicitly labeled for a different platform', async () => {
|
||||
// Regression: an index with one arm64-labeled descriptor and one
|
||||
// unlabeled leaf must not let the unlabeled leaf stand in as amd64
|
||||
// content just because SOME descriptor in the index is unlabeled.
|
||||
const unrelatedLeaf = `sha256:${'6'.repeat(64)}`;
|
||||
const mixedIndexBody = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: INDEX_CONTENT_TYPE,
|
||||
manifests: [
|
||||
{ digest: CHILD_ARM64, mediaType: 'application/vnd.oci.image.manifest.v1+json', platform: { os: 'linux', architecture: 'arm64' } },
|
||||
{ digest: unrelatedLeaf, mediaType: 'application/vnd.oci.image.manifest.v1+json' },
|
||||
],
|
||||
});
|
||||
const mixedIndexDigest = contentDigest(mixedIndexBody);
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': mixedIndexDigest, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(mixedIndexDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': mixedIndexDigest }, body: mixedIndexBody };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
// A local candidate that matches neither the arm64 descriptor nor the
|
||||
// unlabeled leaf: with no confirmed amd64 variant, this must fail closed.
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no confirmed linux/amd64 variant') });
|
||||
});
|
||||
|
||||
it('errors (not update) when a nested index also has no descriptor for the local platform after full expansion', async () => {
|
||||
const nestedIndexBody = indexBody([{ digest: CHILD_ARM64, os: 'linux', architecture: 'arm64' }]);
|
||||
const nestedIndexDigest = contentDigest(nestedIndexBody);
|
||||
const outerIndexBody = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
mediaType: INDEX_CONTENT_TYPE,
|
||||
manifests: [{ digest: nestedIndexDigest, mediaType: INDEX_CONTENT_TYPE }],
|
||||
});
|
||||
const outerIndexDigest = contentDigest(outerIndexBody);
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': outerIndexDigest, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(outerIndexDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': outerIndexDigest }, body: outerIndexBody };
|
||||
}
|
||||
if (url === manifestDigestUrl(nestedIndexDigest) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': nestedIndexDigest }, body: nestedIndexBody };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
// The outer index only nests an arm64-only child index; an amd64 node has
|
||||
// no descriptor anywhere in the fully-expanded tree.
|
||||
const result = await compareLocalToRemoteTag([STALE_INDEX], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('no linux/amd64 variant') });
|
||||
});
|
||||
|
||||
it('never re-fetches the mutable tag: the expansion GET targets the primary digest from HEAD, not a second tag lookup', async () => {
|
||||
const DIVERGED_DIGEST = `sha256:${'e'.repeat(64)}`;
|
||||
let tagCallCount = 0;
|
||||
@@ -544,7 +866,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(tagCallCount).toBe(1);
|
||||
});
|
||||
@@ -555,7 +877,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
? { statusCode: 200, headers: { 'docker-content-digest': SINGLE_DIGEST, 'content-type': 'application/vnd.docker.distribution.manifest.v2+json' } }
|
||||
: { statusCode: 500, headers: {} }
|
||||
);
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'update' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toHaveLength(1);
|
||||
});
|
||||
@@ -570,7 +892,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.url.includes('/manifests/'))).toEqual([
|
||||
{ url: MANIFEST_URL_TAG, method: 'HEAD' },
|
||||
@@ -590,7 +912,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
expect(calls.filter((c) => c.url === MANIFEST_URL_TAG)).toHaveLength(1);
|
||||
});
|
||||
@@ -609,9 +931,9 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(first.kind).toBe('error');
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(second.kind).toBe('error');
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -635,12 +957,12 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MANIFEST_CLASSIFICATION_CACHE_TTL_MS + 1000);
|
||||
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(second).toEqual({ kind: 'match' });
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -659,8 +981,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(digestGetCount).toBe(1);
|
||||
});
|
||||
|
||||
@@ -679,8 +1001,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const [a, b] = await Promise.all([
|
||||
compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64),
|
||||
compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64),
|
||||
compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64),
|
||||
compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64),
|
||||
]);
|
||||
expect(a).toEqual({ kind: 'match' });
|
||||
expect(b).toEqual({ kind: 'match' });
|
||||
@@ -688,7 +1010,13 @@ describe('compareLocalToRemoteTag', () => {
|
||||
});
|
||||
|
||||
it('misses the cache when the primary digest changes (new manifest, new immutable key)', async () => {
|
||||
const INDEX_BODY_2 = indexBody([{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' }]);
|
||||
// Carries a genuinely different arm64 child (not CHILD_ARM64) so the second
|
||||
// lookup is a real same-platform mismatch, not an absent-platform index.
|
||||
const NEW_CHILD_ARM64 = `sha256:${'9'.repeat(64)}`;
|
||||
const INDEX_BODY_2 = indexBody([
|
||||
{ digest: CHILD_AMD64, os: 'linux', architecture: 'amd64' },
|
||||
{ digest: NEW_CHILD_ARM64, os: 'linux', architecture: 'arm64' },
|
||||
]);
|
||||
const INDEX_DIGEST_2 = contentDigest(INDEX_BODY_2);
|
||||
let headDigest = INDEX_DIGEST;
|
||||
let digestGetCount = 0;
|
||||
@@ -708,11 +1036,11 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const first = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
|
||||
headDigest = INDEX_DIGEST_2;
|
||||
const second = await compareLocalToRemoteTag(CHILD_ARM64, REGISTRY, REPO, TAG, ARM64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_ARM64], REGISTRY, REPO, TAG, ARM64);
|
||||
expect(second).toEqual({ kind: 'update' });
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
@@ -734,11 +1062,11 @@ describe('compareLocalToRemoteTag', () => {
|
||||
};
|
||||
|
||||
route = routeFor(REPO);
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(digestGetCount).toBe(1);
|
||||
|
||||
route = routeFor('otherorg/otherapp');
|
||||
await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, 'otherorg/otherapp', TAG, AMD64);
|
||||
await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, 'otherorg/otherapp', TAG, AMD64);
|
||||
expect(digestGetCount).toBe(2);
|
||||
});
|
||||
|
||||
@@ -761,7 +1089,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(VARIANT_V7, REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
|
||||
const result = await compareLocalToRemoteTag([VARIANT_V7], REGISTRY, REPO, TAG, { os: 'linux', architecture: 'arm' });
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -797,12 +1125,12 @@ describe('compareLocalToRemoteTag', () => {
|
||||
|
||||
// A local digest equal to the filtered-out annotated-attestation entry must
|
||||
// never match, since that descriptor is dropped before the membership check.
|
||||
const filtered = await compareLocalToRemoteTag(ATTESTATION_ANNOTATED, REGISTRY, REPO, TAG, AMD64);
|
||||
const filtered = await compareLocalToRemoteTag([ATTESTATION_ANNOTATED], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(filtered).toEqual({ kind: 'update' });
|
||||
|
||||
// The real platform descriptor still matches normally (cache hit reuses the
|
||||
// same parsed classification from the previous call).
|
||||
const realMatch = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const realMatch = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(realMatch).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -825,7 +1153,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -843,7 +1171,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -860,7 +1188,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('mismatched digest') });
|
||||
});
|
||||
|
||||
@@ -879,7 +1207,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: expect.stringContaining('does not match the requested digest') });
|
||||
});
|
||||
|
||||
@@ -895,17 +1223,53 @@ describe('compareLocalToRemoteTag', () => {
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, { os: '', architecture: '' });
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, { os: '', architecture: '' });
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
it('rejects a truncated local digest as an error, never as a speculative update', async () => {
|
||||
route = () => ({ statusCode: 500, headers: {} }); // must never be reached
|
||||
const result = await compareLocalToRemoteTag('sha256:tooshort', REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag(['sha256:tooshort'], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an empty candidate list as an error without contacting the registry', async () => {
|
||||
route = () => ({ statusCode: 500, headers: {} });
|
||||
const result = await compareLocalToRemoteTag([], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an all-malformed candidate list as an error without contacting the registry', async () => {
|
||||
route = () => ({ statusCode: 500, headers: {} });
|
||||
const result = await compareLocalToRemoteTag(['sha256:short', 'not-a-digest'], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'error', reason: 'Local digest is malformed or truncated' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('errors on unknown platform for a multi-candidate index mismatch (fail closed)', async () => {
|
||||
route = (url, method) => {
|
||||
const token = tokenOk(url);
|
||||
if (token) return token;
|
||||
if (url === MANIFEST_URL_TAG && method === 'HEAD') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST, 'content-type': INDEX_CONTENT_TYPE } };
|
||||
}
|
||||
if (url === manifestDigestUrl(INDEX_DIGEST) && method === 'GET') {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': INDEX_DIGEST }, body: STANDARD_INDEX_BODY };
|
||||
}
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
const result = await compareLocalToRemoteTag(
|
||||
[STALE_INDEX, `sha256:${'e'.repeat(64)}`],
|
||||
REGISTRY,
|
||||
REPO,
|
||||
TAG,
|
||||
{ os: '', architecture: '' },
|
||||
);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
it('degrades to an uncached comparison (not an error) when the classification cache is at capacity', async () => {
|
||||
const cache = CacheService.getInstance();
|
||||
for (let i = 0; i < 1000; i++) cache.set(`filler:${i}`, { kind: 'single' as const }, 3_600_000);
|
||||
@@ -925,8 +1289,8 @@ describe('compareLocalToRemoteTag', () => {
|
||||
return { statusCode: 500, headers: {} };
|
||||
};
|
||||
|
||||
const first = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const first = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
const second = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
|
||||
expect(first).toEqual({ kind: 'match' });
|
||||
expect(second).toEqual({ kind: 'match' });
|
||||
@@ -964,7 +1328,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -992,7 +1356,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: nestedBody,
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
expect(calls.filter((c) => c.method === 'GET' && c.url.includes('/manifests/'))).toHaveLength(2);
|
||||
});
|
||||
@@ -1018,7 +1382,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: { statusCode: 404, headers: {} },
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1038,7 +1402,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -1065,7 +1429,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
|
||||
route = routePrimaryDigest(outerDigest, digests);
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind === 'error') {
|
||||
expect(result.reason).toMatch(/depth/i);
|
||||
@@ -1087,7 +1451,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1111,7 +1475,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
[nestedDigest]: nestedBody,
|
||||
});
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result).toEqual({ kind: 'match' });
|
||||
});
|
||||
|
||||
@@ -1131,7 +1495,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const outerDigest = contentDigest(outerBody);
|
||||
route = routePrimaryDigest(outerDigest, { [outerDigest]: outerBody });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
expect(calls.some((c) => c.url.includes('../') || c.url.includes('/evil'))).toBe(false);
|
||||
});
|
||||
@@ -1147,7 +1511,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
|
||||
@@ -1161,7 +1525,7 @@ describe('compareLocalToRemoteTag', () => {
|
||||
const primary = contentDigest(body);
|
||||
route = routePrimaryDigest(primary, { [primary]: body });
|
||||
|
||||
const result = await compareLocalToRemoteTag(CHILD_AMD64, REGISTRY, REPO, TAG, AMD64);
|
||||
const result = await compareLocalToRemoteTag([CHILD_AMD64], REGISTRY, REPO, TAG, AMD64);
|
||||
expect(result.kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks?window_hours=48').set('Cookie', adminCookie);
|
||||
@@ -108,7 +108,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
db.createScheduledTask({
|
||||
name: 'daily-snapshot',
|
||||
@@ -127,7 +127,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
db.createScheduledTask({
|
||||
name: 'system-prune',
|
||||
@@ -146,7 +146,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
last_error: null,
|
||||
prune_targets: JSON.stringify(['images']),
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
|
||||
@@ -440,7 +440,7 @@ describe('GET /api/scheduled-tasks/:id', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -468,7 +468,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
|
||||
@@ -490,7 +490,7 @@ describe('PATCH /api/scheduled-tasks/:id/toggle', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
const off = await request(app).patch(`/api/scheduled-tasks/${id}/toggle`).set('Cookie', adminCookie);
|
||||
@@ -515,7 +515,7 @@ describe('DELETE /api/scheduled-tasks/:id', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const del = await request(app).delete(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
|
||||
@@ -534,7 +534,7 @@ describe('GET /api/scheduled-tasks/:id/runs', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app).get(`/api/scheduled-tasks/${id}/runs`).set('Cookie', adminCookie);
|
||||
@@ -712,7 +712,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -736,7 +736,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
|
||||
});
|
||||
|
||||
const runAt = new Date(new Date().getFullYear() + 1, 6, 1, 23, 0, 0, 0).getTime();
|
||||
@@ -757,7 +757,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
// Editing to a recurring daily schedule sends run_at: null (the frontend
|
||||
@@ -780,7 +780,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: pinned, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1, run_at: pinned,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1, run_at: pinned,
|
||||
});
|
||||
|
||||
// Disabling via Save: next_run_at clears, but the pinned instant is retained
|
||||
@@ -800,7 +800,7 @@ describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
name: 'once', target_type: 'stack', target_id: 's', node_id: 1, action: 'auto_backup',
|
||||
cron_expression: '0 23 1 7 *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: now + 1000, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 1,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 1,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -821,7 +821,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -862,7 +862,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
|
||||
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
@@ -881,7 +881,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
name: 'prune', target_type: 'system', target_id: null, node_id: 1, action: 'prune',
|
||||
cron_expression: '0 4 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
});
|
||||
const remoteNodeId = db.addNode({
|
||||
name: 'remote-prune-update-node', type: 'remote', api_url: 'http://remote.local:1852',
|
||||
@@ -937,7 +937,7 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: JSON.stringify(['api']),
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
delete_after_run: 0,
|
||||
});
|
||||
|
||||
@@ -966,7 +966,7 @@ describe('PUT /api/scheduled-tasks/:id - cron validation', () => {
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, selector_type: null, selector_value: null, delete_after_run: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1087,3 +1087,83 @@ describe('scheduled-tasks state-invalidate broadcast', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST/PUT /api/scheduled-tasks - stack-label selector', () => {
|
||||
const labelPayload = {
|
||||
name: 'label-update',
|
||||
target_type: 'fleet',
|
||||
target_id: null,
|
||||
node_id: null,
|
||||
action: 'update',
|
||||
cron_expression: '0 3 * * *',
|
||||
enabled: true,
|
||||
selector_type: 'stack-label',
|
||||
selector_value: 'production',
|
||||
};
|
||||
|
||||
it('creates a fleet-wide label update with node_id null', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.selector_type).toBe('stack-label');
|
||||
expect(res.body.selector_value).toBe('production');
|
||||
expect(res.body.node_id).toBeNull();
|
||||
expect(res.body.action).toBe('update');
|
||||
expect(res.body.target_type).toBe('fleet');
|
||||
});
|
||||
|
||||
it('persists selector_value changes through PUT (column map)', async () => {
|
||||
const create = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send(labelPayload);
|
||||
expect(create.status).toBe(201);
|
||||
const id = create.body.id as number;
|
||||
|
||||
const put = await request(app)
|
||||
.put(`/api/scheduled-tasks/${id}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ selector_type: 'stack-label', selector_value: 'staging' });
|
||||
expect(put.status).toBe(200);
|
||||
expect(put.body.selector_value).toBe('staging');
|
||||
|
||||
const get = await request(app).get(`/api/scheduled-tasks/${id}`).set('Cookie', adminCookie);
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.selector_value).toBe('staging');
|
||||
});
|
||||
|
||||
it('still requires node_id for non-selector fleet updates', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'fleet-no-node',
|
||||
target_type: 'fleet',
|
||||
action: 'update',
|
||||
cron_expression: '0 3 * * *',
|
||||
node_id: null,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Fleet update requires node_id.');
|
||||
});
|
||||
|
||||
it('rejects selector fields on unsupported actions', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'restart-with-selector',
|
||||
target_type: 'stack',
|
||||
target_id: 'my-stack',
|
||||
node_id: 1,
|
||||
action: 'restart',
|
||||
cron_expression: '0 3 * * *',
|
||||
selector_type: 'stack-label',
|
||||
selector_value: 'prod',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/selector fields can only be used with update action on fleet target/);
|
||||
});
|
||||
|
||||
it('creates a node-scoped label update when node_id is set', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...labelPayload,
|
||||
name: 'label-update-node',
|
||||
node_id: 1,
|
||||
selector_value: 'Databases',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.node_id).toBe(1);
|
||||
expect(res.body.selector_value).toBe('Databases');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,8 @@ const {
|
||||
mockStartContainer, mockStopContainer, mockPruneSystem,
|
||||
mockUpdateStack,
|
||||
mockGetStacks, mockGetStackContent, mockGetEnvContent,
|
||||
mockCheckImage,
|
||||
mockDispatchAlert,
|
||||
mockCheckImage, mockRecheckStack,
|
||||
mockDispatchAlert, mockBroadcastEvent,
|
||||
mockGetProxyTarget,
|
||||
mockIsTrivyAvailable,
|
||||
mockScanAllNodeImages,
|
||||
@@ -59,7 +59,9 @@ const {
|
||||
mockGetStackContent: vi.fn().mockResolvedValue(''),
|
||||
mockGetEnvContent: vi.fn().mockResolvedValue(''),
|
||||
mockCheckImage: vi.fn().mockResolvedValue({ hasUpdate: false }),
|
||||
mockRecheckStack: vi.fn().mockResolvedValue({ outcome: 'cleared', warning: null }),
|
||||
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
|
||||
mockBroadcastEvent: vi.fn(),
|
||||
mockGetProxyTarget: vi.fn().mockReturnValue(null),
|
||||
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
|
||||
mockScanAllNodeImages: vi.fn().mockResolvedValue({
|
||||
@@ -165,16 +167,22 @@ vi.mock('../services/FileSystemService', () => ({
|
||||
|
||||
vi.mock('../services/ImageUpdateService', () => ({
|
||||
ImageUpdateService: {
|
||||
// Default on so existing executeUpdate tests keep prior behavior.
|
||||
isChecksEnabled: () => true,
|
||||
getInstance: () => ({
|
||||
checkImage: mockCheckImage,
|
||||
recheckStack: mockRecheckStack,
|
||||
}),
|
||||
},
|
||||
UPDATE_VERIFICATION_INCOMPLETE_WARNING:
|
||||
'The update command completed, but Sencho could not fully verify whether an image update remains.',
|
||||
}));
|
||||
|
||||
vi.mock('../services/NotificationService', () => ({
|
||||
NotificationService: {
|
||||
getInstance: () => ({
|
||||
dispatchAlert: mockDispatchAlert,
|
||||
broadcastEvent: mockBroadcastEvent,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -789,13 +797,53 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true }); // Update available
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true }); // Update available
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(80);
|
||||
|
||||
expect(mockUpdateStack).toHaveBeenCalledWith('web-app', undefined, true);
|
||||
expect(mockClearStackUpdateStatus).toHaveBeenCalledWith(1, 'web-app');
|
||||
expect(mockRecheckStack).toHaveBeenCalledWith(1, 'web-app');
|
||||
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockBroadcastEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: 1,
|
||||
stackName: 'web-app',
|
||||
}));
|
||||
});
|
||||
|
||||
it('blocks scheduled Compose apply when a sibling image check failed', async () => {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 186,
|
||||
name: 'update-check-errors',
|
||||
action: 'update',
|
||||
cron_expression: '0 4 * * *',
|
||||
enabled: true,
|
||||
target_id: 'web-app',
|
||||
node_id: 1,
|
||||
created_by: 'admin',
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
{ Id: 'c2', Image: 'redis:latest' },
|
||||
]);
|
||||
mockCheckImage
|
||||
.mockResolvedValueOnce({ hasUpdate: true, digestUpdate: true, tagUpdate: false })
|
||||
.mockResolvedValueOnce({ hasUpdate: false, error: 'registry timeout', checkStatus: 'failed' });
|
||||
|
||||
await SchedulerService.getInstance().triggerTask(186);
|
||||
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
expect(mockRecheckStack).not.toHaveBeenCalled();
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
status: 'success',
|
||||
output: expect.stringContaining('image check(s) failed'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs a scheduled update on the community tier (no paid gate)', async () => {
|
||||
@@ -814,7 +862,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(82);
|
||||
@@ -828,7 +876,15 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
|
||||
it('begins a health gate after a scheduled update succeeds', async () => {
|
||||
const { HealthGateService } = await import('../services/HealthGateService');
|
||||
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockReturnValue('gate-1');
|
||||
const callOrder: string[] = [];
|
||||
const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'beginStack').mockImplementation(() => {
|
||||
callOrder.push('beginStack');
|
||||
return 'gate-1';
|
||||
});
|
||||
mockRecheckStack.mockImplementation(async () => {
|
||||
callOrder.push('recheckStack');
|
||||
return { outcome: 'cleared', warning: null };
|
||||
});
|
||||
try {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 83,
|
||||
@@ -842,11 +898,13 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
await SchedulerService.getInstance().triggerTask(83);
|
||||
|
||||
expect(beginSpy).toHaveBeenCalledWith(1, 'web-app', 'update', 'system:scheduler');
|
||||
expect(mockRecheckStack).toHaveBeenCalledWith(1, 'web-app');
|
||||
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
||||
} finally {
|
||||
beginSpy.mockRestore();
|
||||
}
|
||||
@@ -875,6 +933,45 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it('skips Compose apply for tag-only availability (pinned semver cannot be rewritten)', async () => {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 185,
|
||||
name: 'update-tag-only',
|
||||
action: 'update',
|
||||
cron_expression: '0 4 * * *',
|
||||
enabled: true,
|
||||
target_id: 'web-app',
|
||||
node_id: 1,
|
||||
created_by: 'admin',
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:1.2.3' },
|
||||
]);
|
||||
// Higher tag is visible (hasUpdate) but not actionable via Compose pull.
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: false, tagUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(84);
|
||||
|
||||
expect(mockUpdateStack).not.toHaveBeenCalled();
|
||||
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(mockDispatchAlert).not.toHaveBeenCalledWith(
|
||||
'info',
|
||||
'image_update_applied',
|
||||
expect.any(String),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
status: 'success',
|
||||
output: expect.stringContaining('Compose pin unchanged'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles wildcard target (*) by updating all stacks', async () => {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 82,
|
||||
@@ -891,7 +988,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
mockGetContainersByStack.mockResolvedValue([
|
||||
{ Id: 'c1', Image: 'nginx:latest' },
|
||||
]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(82);
|
||||
@@ -1035,7 +1132,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
last_status: null,
|
||||
});
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:1.14' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
mockEnforcePolicyPreDeploy.mockResolvedValue({
|
||||
ok: false,
|
||||
bypassed: false,
|
||||
@@ -1114,7 +1211,7 @@ describe('SchedulerService - executeUpdate', () => {
|
||||
});
|
||||
mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]);
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true });
|
||||
mockCheckImage.mockResolvedValue({ hasUpdate: true, digestUpdate: true });
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(87);
|
||||
@@ -1778,7 +1875,7 @@ function makeLifecycleTask(action: ScheduledTask['action'], overrides: Partial<S
|
||||
last_error: null,
|
||||
prune_targets: null,
|
||||
target_services: null,
|
||||
prune_label_filter: null,
|
||||
prune_label_filter: null, selector_type: null, selector_value: null,
|
||||
delete_after_run: 0,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../helpers/selfUpdateCompose';
|
||||
import {
|
||||
buildComposeReadArgs,
|
||||
buildComposeConfigValidateArgs,
|
||||
buildSelfUpdateComposeCmd,
|
||||
buildSelfUpdateRunArgs,
|
||||
shQuote,
|
||||
@@ -302,3 +303,22 @@ describe('buildSelfUpdateRunArgs (repinWritable branch)', () => {
|
||||
expect(args).not.toContain('/opt/sencho:/opt/sencho:rw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildComposeConfigValidateArgs', () => {
|
||||
it('runs compose config in a throwaway helper with the working dir mounted read-only', () => {
|
||||
const args = buildComposeConfigValidateArgs({
|
||||
workingDir: '/opt/sencho',
|
||||
imageName: 'saelix/sencho:1.0.0',
|
||||
configFiles: 'docker-compose.yml,/opt/sencho/override.yml',
|
||||
hostBindMounts: [{ source: '/etc/sencho', destination: '/etc/sencho' }],
|
||||
});
|
||||
expect(args).toContain('/opt/sencho:/opt/sencho:ro');
|
||||
expect(args).toContain('/var/run/docker.sock:/var/run/docker.sock');
|
||||
expect(args).toContain('/etc/sencho:/etc/sencho:ro');
|
||||
const cmd = args[args.length - 1];
|
||||
expect(cmd).toContain('docker compose');
|
||||
expect(cmd).toContain('config');
|
||||
expect(cmd).toContain(shQuote('docker-compose.yml'));
|
||||
expect(cmd).toContain(shQuote('/opt/sencho/override.yml'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Tests for the two pieces added to fix sessions expiring out from under
|
||||
* active users: sliding-refresh (authMiddleware silently reissues a
|
||||
* near-expiry session cookie) and "stay signed in" (a longer-lived session
|
||||
* chosen at login, carried through MFA and password-change reissues).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { OTP } from 'otplib';
|
||||
import {
|
||||
setupTestDb,
|
||||
cleanupTestDb,
|
||||
loginAsTestAdmin,
|
||||
seedMfaUser,
|
||||
TEST_USERNAME,
|
||||
TEST_PASSWORD,
|
||||
TEST_JWT_SECRET,
|
||||
} from './helpers/setupTestDb';
|
||||
|
||||
// Match the server-side otplib configuration so test-generated OTPs are
|
||||
// accepted by the verify path (see MfaService and __tests__/mfa.test.ts).
|
||||
const authenticator = new OTP({ strategy: 'totp' });
|
||||
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
/** All Set-Cookie entries in a response for one cookie name. */
|
||||
function cookieEntries(setCookieHeader: string | string[] | undefined, name: string): string[] {
|
||||
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
|
||||
return cookies.filter((c) => c.startsWith(`${name}=`));
|
||||
}
|
||||
|
||||
/** All sencho_token Set-Cookie entries in a response (should never be more than one). */
|
||||
function sessionCookieEntries(setCookieHeader: string | string[] | undefined): string[] {
|
||||
return cookieEntries(setCookieHeader, 'sencho_token');
|
||||
}
|
||||
|
||||
/** Extract the sencho_token cookie's raw JWT value from a Set-Cookie header. */
|
||||
function extractSessionToken(setCookieHeader: string | string[] | undefined): string | undefined {
|
||||
const match = sessionCookieEntries(setCookieHeader)[0];
|
||||
return match?.split(';')[0].split('=')[1];
|
||||
}
|
||||
|
||||
describe('sliding session refresh', () => {
|
||||
afterEach(() => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1');
|
||||
});
|
||||
|
||||
it('refreshes a session nearing expiry', async () => {
|
||||
const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Authorization', `Bearer ${nearExpiryToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const refreshed = extractSessionToken(res.headers['set-cookie']);
|
||||
expect(refreshed).toBeDefined();
|
||||
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean };
|
||||
// Refreshed back to a full 24h session, not just extended by seconds.
|
||||
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(decoded.remember).toBe(false);
|
||||
});
|
||||
|
||||
it('does not refresh a session with plenty of life left', async () => {
|
||||
const freshCookie = await loginAsTestAdmin(app);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Cookie', freshCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reissues a "stay signed in" session back to a 30-day TTL, not 24h', async () => {
|
||||
const nearExpiryRememberToken = jwt.sign({ username: TEST_USERNAME, remember: true }, TEST_JWT_SECRET, { expiresIn: '30s' });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Authorization', `Bearer ${nearExpiryRememberToken}`);
|
||||
|
||||
const refreshed = extractSessionToken(res.headers['set-cookie']);
|
||||
expect(refreshed).toBeDefined();
|
||||
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean };
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('does not refresh when session_sliding_refresh is disabled', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '0');
|
||||
const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' });
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Authorization', `Bearer ${nearExpiryToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
// The refresh sits after the token-version check in authMiddleware, which is
|
||||
// what makes it safe: a stale token cannot ride the refresh back to life. If
|
||||
// that ordering ever moved, these would be the tests to catch it.
|
||||
it('rejects (does not refresh) a near-expiry token with a stale token_version', async () => {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!;
|
||||
const staleToken = jwt.sign(
|
||||
{ username: TEST_USERNAME, remember: false, tv: user.token_version - 1 },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '30s' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Authorization', `Bearer ${staleToken}`);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refreshes a near-expiry token with a current token_version and carries it forward', async () => {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!;
|
||||
const currentToken = jwt.sign(
|
||||
{ username: TEST_USERNAME, remember: false, tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '30s' },
|
||||
);
|
||||
const res = await request(app)
|
||||
.get('/api/auth/check')
|
||||
.set('Authorization', `Bearer ${currentToken}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const refreshed = extractSessionToken(res.headers['set-cookie']);
|
||||
expect(refreshed).toBeDefined();
|
||||
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; tv?: number };
|
||||
expect(decoded.tv).toBe(user.token_version);
|
||||
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('"stay signed in" at login', () => {
|
||||
it('issues a 30-day session when remember is true', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD, remember: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const cookieEntry = sessionCookieEntries(res.headers['set-cookie'])[0];
|
||||
expect(cookieEntry).toBeDefined();
|
||||
// The cookie's own Max-Age must match the JWT's exp, or a browser would
|
||||
// drop the cookie before the token expires, defeating "stay signed in"
|
||||
// even though the token itself looks correct.
|
||||
expect(cookieEntry).toMatch(/Max-Age=2592000/);
|
||||
const token = extractSessionToken(res.headers['set-cookie']);
|
||||
expect(token).toBeDefined();
|
||||
const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
|
||||
});
|
||||
|
||||
it('issues the standard 24h session when remember is omitted', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
|
||||
const token = extractSessionToken(res.headers['set-cookie']);
|
||||
const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
|
||||
expect(decoded.remember).toBe(false);
|
||||
expect(decoded.exp - decoded.iat).toBeCloseTo(24 * 60 * 60, -2);
|
||||
});
|
||||
|
||||
it('carries remember through an MFA challenge to the final session', async () => {
|
||||
const { secret } = await seedMfaUser('mfa-remember-user', 'mfa-remember-pass');
|
||||
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'mfa-remember-user', password: 'mfa-remember-pass', remember: true });
|
||||
expect(loginRes.body.mfaRequired).toBe(true);
|
||||
const pendingCookieHeader = cookieEntries(loginRes.headers['set-cookie'], 'sencho_mfa_pending')[0];
|
||||
expect(pendingCookieHeader).toBeDefined();
|
||||
|
||||
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
|
||||
const mfaRes = await request(app)
|
||||
.post('/api/auth/login/mfa')
|
||||
.set('Cookie', pendingCookieHeader!)
|
||||
.send({ code });
|
||||
|
||||
expect(mfaRes.status).toBe(200);
|
||||
const finalToken = extractSessionToken(mfaRes.headers['set-cookie']);
|
||||
expect(finalToken).toBeDefined();
|
||||
const decoded = jwt.verify(finalToken!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reissueSessionAfterTokenBump preserves "stay signed in"', () => {
|
||||
it('keeps a 30-day session after a password change', async () => {
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const passwordHash = await bcrypt.hash('bump-test-pass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'bump-test-user', password_hash: passwordHash, role: 'admin' });
|
||||
|
||||
const loginRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'bump-test-user', password: 'bump-test-pass', remember: true });
|
||||
const rememberedCookie = extractSessionToken(loginRes.headers['set-cookie']);
|
||||
const cookieHeader = sessionCookieEntries(loginRes.headers['set-cookie'])[0];
|
||||
|
||||
const changeRes = await request(app)
|
||||
.put('/api/auth/password')
|
||||
.set('Cookie', cookieHeader!)
|
||||
.send({ oldPassword: 'bump-test-pass', newPassword: 'bump-test-pass-2' });
|
||||
|
||||
expect(changeRes.status).toBe(200);
|
||||
// Exactly one sencho_token Set-Cookie, not one from the sliding refresh
|
||||
// (pre-bump token_version) followed by a second from the post-bump
|
||||
// reissue: a second, stale entry would leave any client that reads the
|
||||
// first Set-Cookie signed out on its very next request.
|
||||
expect(sessionCookieEntries(changeRes.headers['set-cookie'])).toHaveLength(1);
|
||||
const reissued = extractSessionToken(changeRes.headers['set-cookie']);
|
||||
expect(reissued).toBeDefined();
|
||||
expect(reissued).not.toBe(rememberedCookie);
|
||||
const decoded = jwt.verify(reissued!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
|
||||
expect(decoded.remember).toBe(true);
|
||||
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
|
||||
});
|
||||
|
||||
it('does not duplicate the session cookie when the password change lands inside the sliding-refresh window', async () => {
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const passwordHash = await bcrypt.hash('bump-window-pass', 1);
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'bump-window-user', password_hash: passwordHash, role: 'admin' });
|
||||
const user = db.getUserByUsername('bump-window-user')!;
|
||||
|
||||
// Hand-sign a near-expiry token so authMiddleware's sliding refresh fires
|
||||
// on this very request, immediately before the route bumps token_version.
|
||||
const nearExpiryToken = jwt.sign(
|
||||
{ username: 'bump-window-user', remember: true, tv: user.token_version },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '30s' },
|
||||
);
|
||||
|
||||
const changeRes = await request(app)
|
||||
.put('/api/auth/password')
|
||||
.set('Authorization', `Bearer ${nearExpiryToken}`)
|
||||
.send({ oldPassword: 'bump-window-pass', newPassword: 'bump-window-pass-2' });
|
||||
|
||||
expect(changeRes.status).toBe(200);
|
||||
const entries = sessionCookieEntries(changeRes.headers['set-cookie']);
|
||||
expect(entries).toHaveLength(1);
|
||||
const decoded = jwt.verify(extractSessionToken(changeRes.headers['set-cookie'])!, TEST_JWT_SECRET) as { tv?: number };
|
||||
expect(decoded.tv).toBe(db.getUserByUsername('bump-window-user')!.token_version);
|
||||
});
|
||||
});
|
||||
@@ -241,6 +241,47 @@ describe('prune_on_update (auto-prune after updates)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('session_sliding_refresh (keep active sessions alive)', () => {
|
||||
it('defaults to ON in a freshly seeded database', () => {
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('1');
|
||||
});
|
||||
|
||||
it('is exposed through the settings GET projection', async () => {
|
||||
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.session_sliding_refresh).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts a well-formed write and persists it', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ key: 'session_sliding_refresh', value: '0' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('0');
|
||||
// Restore the seeded default so later suites observe the shipped behavior.
|
||||
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1');
|
||||
});
|
||||
|
||||
it('rejects a non-enum value (400) and does not write it', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ key: 'session_sliding_refresh', value: 'banana' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe('Validation failed');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).not.toBe('banana');
|
||||
});
|
||||
|
||||
it('rejects a non-admin write with 403', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/settings')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ key: 'session_sliding_refresh', value: '0' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune_orphaned_scans (purge scans for deleted images/stacks)', () => {
|
||||
it('defaults to ON in a freshly seeded database', () => {
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('1');
|
||||
@@ -663,19 +704,19 @@ describe('Paid-only setting keys (audit_retention_days)', () => {
|
||||
});
|
||||
|
||||
describe('reclaim_hero setting', () => {
|
||||
it('is allowlisted and seeds to "1" (banner on by default)', async () => {
|
||||
it('is allowlisted and seeds to "0" (banner off by default)', async () => {
|
||||
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reclaim_hero).toBe('1');
|
||||
expect(res.body.reclaim_hero).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts a well-formed write and rejects a non-enum value', async () => {
|
||||
const ok = await request(app)
|
||||
.patch('/api/settings')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ reclaim_hero: '0' });
|
||||
.send({ reclaim_hero: '1' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('1');
|
||||
|
||||
const bad = await request(app)
|
||||
.patch('/api/settings')
|
||||
@@ -683,9 +724,9 @@ describe('reclaim_hero setting', () => {
|
||||
.send({ reclaim_hero: 'banana' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body.error).toBe('Validation failed');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('0');
|
||||
expect(DatabaseService.getInstance().getGlobalSettings().reclaim_hero).toBe('1');
|
||||
|
||||
// Reset for any later reads of the shared test DB.
|
||||
DatabaseService.getInstance().updateGlobalSetting('reclaim_hero', '1');
|
||||
DatabaseService.getInstance().updateGlobalSetting('reclaim_hero', '0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Manual POST /api/stacks/:name/update must start the health gate before
|
||||
* registry recheck, never blind-clear update status, and keep Compose success
|
||||
* as HTTP 200 even when recheck throws.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
const {
|
||||
mockExecute,
|
||||
mockRecheckStack,
|
||||
mockBeginStack,
|
||||
mockClearStackUpdateStatus,
|
||||
mockBroadcastEvent,
|
||||
mockDispatchAlert,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecute: vi.fn(),
|
||||
mockRecheckStack: vi.fn(),
|
||||
mockBeginStack: vi.fn(),
|
||||
mockClearStackUpdateStatus: vi.fn(),
|
||||
mockBroadcastEvent: vi.fn(),
|
||||
mockDispatchAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/StackUpdateOrchestrator', () => ({
|
||||
StackUpdateOrchestrator: {
|
||||
getInstance: () => ({ execute: mockExecute }),
|
||||
},
|
||||
shortImageId: (id: string) => id.slice(0, 12),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ImageUpdateService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../services/ImageUpdateService')>(
|
||||
'../services/ImageUpdateService',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ImageUpdateService: {
|
||||
isChecksEnabled: () => true,
|
||||
getInstance: () => ({ recheckStack: mockRecheckStack }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/HealthGateService', () => ({
|
||||
HealthGateService: {
|
||||
getInstance: () => ({
|
||||
beginStack: mockBeginStack,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: {
|
||||
getInstance: () => ({
|
||||
getBaseDir: () => '/tmp/compose',
|
||||
hasComposeFile: vi.fn().mockResolvedValue(true),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/policyGate', async () => {
|
||||
const actual = await vi.importActual<typeof import('../helpers/policyGate')>(
|
||||
'../helpers/policyGate',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
runPolicyGate: vi.fn().mockResolvedValue(true),
|
||||
triggerPostDeployScan: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let clearSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let broadcastSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
const callOrder: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { NotificationService } = await import('../services/NotificationService');
|
||||
clearSpy = vi.spyOn(DatabaseService.getInstance(), 'clearStackUpdateStatus').mockImplementation((...args) => {
|
||||
callOrder.push('clearStackUpdateStatus');
|
||||
return mockClearStackUpdateStatus(...args);
|
||||
});
|
||||
broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation((...args) => {
|
||||
callOrder.push('broadcastEvent');
|
||||
return mockBroadcastEvent(...args);
|
||||
});
|
||||
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockImplementation((...args) => {
|
||||
callOrder.push(`dispatchAlert:${String(args[2] ?? args[0])}`);
|
||||
return mockDispatchAlert(...args) ?? Promise.resolve({ persisted: true });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
clearSpy?.mockRestore();
|
||||
broadcastSpy?.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
callOrder.length = 0;
|
||||
mockExecute.mockReset();
|
||||
mockRecheckStack.mockReset();
|
||||
mockBeginStack.mockReset();
|
||||
mockClearStackUpdateStatus.mockReset();
|
||||
mockBroadcastEvent.mockReset();
|
||||
mockDispatchAlert.mockReset().mockResolvedValue({ persisted: true });
|
||||
|
||||
mockExecute.mockImplementation(async () => {
|
||||
callOrder.push('execute');
|
||||
return { kind: 'stack_compose_done', recoveryId: null };
|
||||
});
|
||||
mockBeginStack.mockImplementation(() => {
|
||||
callOrder.push('beginStack');
|
||||
return 'gate-1';
|
||||
});
|
||||
mockRecheckStack.mockImplementation(async () => {
|
||||
callOrder.push('recheckStack');
|
||||
return { outcome: 'cleared', warning: null };
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:name/update post-compose verification', () => {
|
||||
it('starts the health gate before recheck, skips clear, and broadcasts after recheck', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ skip_scan: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status: 'Update completed', healthGateId: 'gate-1' });
|
||||
expect(res.body.recheckWarning).toBeUndefined();
|
||||
expect(callOrder.indexOf('execute')).toBeLessThan(callOrder.indexOf('beginStack'));
|
||||
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
||||
expect(callOrder.indexOf('recheckStack')).toBeLessThan(callOrder.indexOf('broadcastEvent'));
|
||||
expect(callOrder).not.toContain('clearStackUpdateStatus');
|
||||
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns recheckWarning when the update condition remains', async () => {
|
||||
mockRecheckStack.mockImplementation(async () => {
|
||||
callOrder.push('recheckStack');
|
||||
return {
|
||||
outcome: 'still_present',
|
||||
warning: 'The update command completed, but Sencho still detects an available image update.',
|
||||
};
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ skip_scan: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.recheckWarning).toBe(
|
||||
'The update command completed, but Sencho still detects an available image update.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps HTTP 200 and success notification when recheck throws after Compose', async () => {
|
||||
mockRecheckStack.mockImplementation(async () => {
|
||||
callOrder.push('recheckStack');
|
||||
throw new Error('registry blew up');
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ skip_scan: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.healthGateId).toBe('gate-1');
|
||||
expect(res.body.recheckWarning).toMatch(/could not fully verify/i);
|
||||
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
|
||||
expect(callOrder).toContain('broadcastEvent');
|
||||
// Success path still notifies; failure notification must not fire.
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'image_update_applied',
|
||||
expect.any(String),
|
||||
expect.objectContaining({ stackName: 'web' }),
|
||||
);
|
||||
expect(mockDispatchAlert.mock.calls.some((c) => c[0] === 'error' && c[1] === 'deploy_failure')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('buildMounts', () => {
|
||||
{ type: 'bind', source: '/app/stack/conf', target: '/conf', readOnly: true },
|
||||
{ type: 'named', source: 'shared', target: '/s', readOnly: false },
|
||||
],
|
||||
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
},
|
||||
],
|
||||
networks: {},
|
||||
@@ -161,7 +161,7 @@ describe('assembleStorageInventory', () => {
|
||||
projectName: 'a', services: [{
|
||||
name: 'app', ports: [], binds: [], namedVolumes: [],
|
||||
storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }],
|
||||
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
}], networks: {}, volumes: {},
|
||||
};
|
||||
expect(assembleStorageInventory('a', stateful, null, new Map()).stateful).toBe(true);
|
||||
@@ -178,7 +178,7 @@ describe('assembleStorageInventory', () => {
|
||||
projectName: 'a', services: [{
|
||||
name: 'app', ports: [], binds: [], namedVolumes: [],
|
||||
storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false }],
|
||||
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
privileged: false, hasHealthcheck: true, composeHealthcheck: 'active', envKeys: [], networks: [], extraHosts: [], labelKeys: [],
|
||||
}], networks: {}, volumes: {},
|
||||
};
|
||||
expect(assembleStorageInventory('a', socketOnly, null, new Map()).stateful).toBe(false);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
buildServicesSignal,
|
||||
} from '../services/updateGuard/readiness';
|
||||
import type { ContainerProbe, ReadinessSignal } from '../services/updateGuard/types';
|
||||
import type { UpdatePreviewSummary } from '../services/UpdatePreviewService';
|
||||
import type { UpdatePreviewImage, UpdatePreviewSummary } from '../services/UpdatePreviewService';
|
||||
|
||||
const NOW = 1_750_000_000_000;
|
||||
|
||||
@@ -37,6 +37,9 @@ const summary = (over: Partial<UpdatePreviewSummary> = {}): UpdatePreviewSummary
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok',
|
||||
verification_failed: false,
|
||||
verification_error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -157,6 +160,13 @@ describe('updatePreviewSignal', () => {
|
||||
it('degrades a preview failure to a non-verdict-affecting unknown', () => {
|
||||
expect(updatePreviewSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
|
||||
});
|
||||
|
||||
it('reports detection disabled without treating it as up to date', () => {
|
||||
const signal = updatePreviewSignal(summary({ detection_disabled: true, has_update: false }));
|
||||
expect(signal.status).toBe('unknown');
|
||||
expect(signal.affectsVerdict).toBe(false);
|
||||
expect(signal.detail).toMatch(/disabled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServicesSignal', () => {
|
||||
@@ -225,3 +235,77 @@ describe('aggregateVerdict', () => {
|
||||
expect(aggregateVerdict([driftSignal(0), preflightSignal({ activeStatus: 'pass' }), healthchecksSignal([probe()])])).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePreviewSignal verification failure', () => {
|
||||
it('does not claim no pending update when digest verification failed', () => {
|
||||
const signal = updatePreviewSignal(summary({
|
||||
verification_failed: true,
|
||||
verification_error: 'Registry unreachable',
|
||||
}));
|
||||
expect(signal.status).toBe('unknown');
|
||||
expect(signal.detail).toMatch(/Digest verification failed/);
|
||||
expect(signal.detail).toMatch(/Registry unreachable/);
|
||||
expect(signal.detail).not.toMatch(/No pending image update detected/);
|
||||
});
|
||||
|
||||
it('holds a confirmed update for review, not ok, when another image failed digest verification', () => {
|
||||
const signal = updatePreviewSignal(summary({
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'digest',
|
||||
verification_failed: true,
|
||||
verification_error: 'Registry unreachable',
|
||||
}));
|
||||
expect(signal.status).toBe('attention');
|
||||
expect(signal.detail).toMatch(/Registry unreachable/);
|
||||
});
|
||||
|
||||
it('holds a pending rebuild for review, not verification-only, when another image failed digest verification', () => {
|
||||
const signal = updatePreviewSignal(summary({
|
||||
rebuild_available: true,
|
||||
has_build_services: true,
|
||||
verification_failed: true,
|
||||
verification_error: 'Registry unreachable',
|
||||
}));
|
||||
expect(signal.status).toBe('attention');
|
||||
expect(signal.detail).toMatch(/rebuild/);
|
||||
expect(signal.detail).toMatch(/Registry unreachable/);
|
||||
});
|
||||
|
||||
const image = (over: Partial<UpdatePreviewImage> = {}): UpdatePreviewImage => ({
|
||||
service: 'web',
|
||||
image: 'nginx:1',
|
||||
current_tag: '1',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'ok',
|
||||
check_error: null,
|
||||
digest_error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('keeps a single image with its own confirmed update ok even though that same image also failed its own digest check', () => {
|
||||
// has_update and check_error are independent per image; there is no
|
||||
// "other image" here, so per-image detail must clear the review hold
|
||||
// that the aggregate-only fallback would otherwise apply.
|
||||
const signal = updatePreviewSignal(
|
||||
summary({ has_update: true, semver_bump: 'patch', update_kind: 'tag', verification_failed: true, verification_error: 'Registry unreachable' }),
|
||||
[image({ has_update: true, check_error: 'Registry unreachable' })],
|
||||
);
|
||||
expect(signal.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('holds a confirmed update for review when per-image detail proves a genuinely different image failed verification', () => {
|
||||
const signal = updatePreviewSignal(
|
||||
summary({ has_update: true, semver_bump: 'patch', update_kind: 'tag', verification_failed: true, verification_error: 'Registry unreachable' }),
|
||||
[
|
||||
image({ service: 'confirmed', has_update: true, check_error: null }),
|
||||
image({ service: 'failing', has_update: false, check_error: 'Registry unreachable' }),
|
||||
],
|
||||
);
|
||||
expect(signal.status).toBe('attention');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,21 @@ describe('UpdateGuardService.probeContainers', () => {
|
||||
expect(probes[0].name).toBe('app-web-1');
|
||||
});
|
||||
|
||||
it('treats Test NONE as no effective healthcheck', async () => {
|
||||
mockListContainers.mockResolvedValue([
|
||||
{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' },
|
||||
]);
|
||||
mockGetContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue(inspectResult({
|
||||
Config: { Healthcheck: { Test: ['NONE'] } },
|
||||
})),
|
||||
});
|
||||
|
||||
const probes = await UpdateGuardService.getInstance().probeContainers(0, 'app');
|
||||
expect(probes).toHaveLength(1);
|
||||
expect(probes[0].hasHealthcheck).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates non-404 inspect failures so the whole signal degrades honestly', async () => {
|
||||
mockListContainers.mockResolvedValue([
|
||||
{ Id: 'aaa', Names: ['/app-web-1'], State: 'running' },
|
||||
@@ -159,7 +174,7 @@ describe('UpdateGuardService.computeUpdateReadiness wiring', () => {
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null,
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -190,7 +205,7 @@ describe('UpdateGuardService.computeUpdateReadiness with a serviceName', () => {
|
||||
summary: {
|
||||
has_update: true, primary_image: 'nginx', current_tag: '1.27.0', next_tag: '1.27.1',
|
||||
semver_bump: 'patch', update_kind: 'tag', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null,
|
||||
},
|
||||
rollback_target: 'nginx:1.27.0',
|
||||
changelog: null,
|
||||
@@ -284,7 +299,7 @@ describe('UpdateGuardService.computeRollbackReadiness moving-tag wiring', () =>
|
||||
summary: {
|
||||
has_update: false, primary_image: 'app', current_tag: images[0]?.current_tag ?? null,
|
||||
next_tag: null, semver_bump: 'none', update_kind: 'none', blocked: false, blocked_reason: null,
|
||||
has_build_services: false, rebuild_available: false,
|
||||
has_build_services: false, rebuild_available: false, check_status: 'ok', verification_failed: false, verification_error: null,
|
||||
},
|
||||
rollback_target: 'app:1.2.3',
|
||||
changelog: null,
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Authoritative-negative update-preview reconcile: commitPreviewClear generation
|
||||
* safety and route side effects (broadcast / fleet cache invalidate).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let ImageUpdateService: typeof import('../services/ImageUpdateService').ImageUpdateService;
|
||||
let UpdatePreviewService: typeof import('../services/UpdatePreviewService').UpdatePreviewService;
|
||||
let NotificationService: typeof import('../services/NotificationService').NotificationService;
|
||||
let CacheService: typeof import('../services/CacheService').CacheService;
|
||||
let adminCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ ImageUpdateService } = await import('../services/ImageUpdateService'));
|
||||
({ UpdatePreviewService } = await import('../services/UpdatePreviewService'));
|
||||
({ NotificationService } = await import('../services/NotificationService'));
|
||||
({ CacheService } = await import('../services/CacheService'));
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const raw = (DatabaseService.getInstance() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
||||
raw.prepare('DELETE FROM stack_update_status').run();
|
||||
});
|
||||
|
||||
function negativeOkPreview(stackName = 'web') {
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images: [{
|
||||
service: 'web',
|
||||
image: 'nginx:1.2.3',
|
||||
current_tag: '1.2.3',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
check_status: 'ok' as const,
|
||||
check_error: null,
|
||||
digest_error: null,
|
||||
}],
|
||||
build_services: [] as string[],
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: 'nginx:1.2.3',
|
||||
current_tag: '1.2.3',
|
||||
next_tag: null,
|
||||
semver_bump: 'none' as const,
|
||||
update_kind: 'none' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok' as const,
|
||||
verification_failed: false,
|
||||
verification_error: null,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageUpdateService.commitPreviewClear', () => {
|
||||
it('clears sticky partial rows and returns cleared', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
]);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
|
||||
const result = await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow);
|
||||
expect(result).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an older confirmed ok+true row', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const writeGen = (svc as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
}).reserveStackWriteGeneration(nodeId, 'web');
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], writeGen);
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web');
|
||||
expect(observedRow).toBe(writeGen);
|
||||
expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a persisted ok+true row when in-memory generation was reset (restart)', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
// Simulate a prior process that wrote generation 7, then a restart that
|
||||
// left only SQLite state (in-memory high-water is 0 for this stack key).
|
||||
db.upsertStackUpdateStatus(nodeId, 'restart-web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 7);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
expect(svc.peekStackWriteGeneration(nodeId, 'restart-web')).toBe(0);
|
||||
expect(db.getStackUpdateWriteGeneration(nodeId, 'restart-web')).toBe(7);
|
||||
expect(await svc.commitPreviewClear(nodeId, 'restart-web', 0, 7)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns absent when no row exists', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
expect(await svc.commitPreviewClear(nodeId, 'missing', 0, 0)).toBe('absent');
|
||||
});
|
||||
|
||||
it('retains a row written after the observation watermark', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
commitPreviewClear: (
|
||||
n: number,
|
||||
s: string,
|
||||
observedMem: number,
|
||||
observedRow: number,
|
||||
) => Promise<'cleared' | 'stale' | 'absent'>;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'race');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'race');
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'race');
|
||||
expect(scannerGen).toBeGreaterThan(observedMem);
|
||||
|
||||
await svc.withStackWriteLock(nodeId, 'race', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'race', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'race', observedMem, observedRow)).toBe('stale');
|
||||
expect(db.getStackUpdateDetail(nodeId).race?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).race?.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('retains a row whose DB generation advanced after observation', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'adv', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 3);
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'adv');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'adv');
|
||||
expect(observedRow).toBe(3);
|
||||
|
||||
db.upsertStackUpdateStatus(nodeId, 'adv', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], 4);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'adv', observedMem, observedRow)).toBe('absent');
|
||||
expect(db.getStackUpdateDetail(nodeId).adv?.hasUpdate).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
|
||||
it('GET returns detection_disabled preview without calling getPreview when checks are off', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('image_update_checks_enabled', '0');
|
||||
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.summary?.detection_disabled).toBe(true);
|
||||
expect(res.body.summary?.has_update).toBe(false);
|
||||
expect(res.body.images).toEqual([]);
|
||||
expect(getPreview).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
db.updateGlobalSetting('image_update_checks_enabled', '1');
|
||||
}
|
||||
});
|
||||
|
||||
it('POST returns detection_disabled without registry I/O or sticky reconcile when checks are off', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
db.updateGlobalSetting('image_update_checks_enabled', '0');
|
||||
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.summary?.detection_disabled).toBe(true);
|
||||
expect(res.body.summary?.has_update).toBe(false);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(getPreview).not.toHaveBeenCalled();
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
db.updateGlobalSetting('image_update_checks_enabled', '1');
|
||||
}
|
||||
});
|
||||
|
||||
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBeUndefined();
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST clears sticky state and broadcasts on authoritative-negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
action: 'update-status-reconciled',
|
||||
stackName: 'web',
|
||||
}));
|
||||
});
|
||||
|
||||
it('POST clears an older confirmed ok+true row and broadcasts', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
};
|
||||
const writeGen = svc.reserveStackWriteGeneration(nodeId, 'web');
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'ok', null, [
|
||||
{ service: 'web', image: 'nginx:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], writeGen);
|
||||
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined();
|
||||
expect(invalidate).toHaveBeenCalledWith('fleet-updates');
|
||||
expect(broadcast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: 'update-status-reconciled',
|
||||
stackName: 'web',
|
||||
}));
|
||||
});
|
||||
|
||||
it('POST does not mutate on partial negative preview', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [{ ...preview.images[0], check_status: 'partial' }],
|
||||
summary: { ...preview.summary, check_status: 'partial' },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not mutate when an image is not_checkable alongside ok', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [
|
||||
preview.images[0],
|
||||
{
|
||||
...preview.images[0],
|
||||
service: 'bad',
|
||||
image: 'not-a-valid-ref',
|
||||
check_status: 'not_checkable' as const,
|
||||
},
|
||||
],
|
||||
summary: { ...preview.summary, check_status: 'partial' as const },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not mutate when check_status is missing from summary rollup fields still fail every-ok', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
|
||||
|
||||
const preview = negativeOkPreview('web');
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue({
|
||||
...preview,
|
||||
images: [{ ...preview.images[0], check_status: 'partial' as const }],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('POST does not broadcast when clear finds no row', async () => {
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('ghost'));
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/ghost/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains a confirmed row written after the preview observation', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const observedBeforePreview = svc.peekStackWriteGeneration(nodeId, 'web');
|
||||
let previewCalls = 0;
|
||||
vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockImplementation(async () => {
|
||||
previewCalls += 1;
|
||||
// Simulate a scanner reservation+commit that begins after observation.
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'web');
|
||||
await svc.withStackWriteLock(nodeId, 'web', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'web', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'nginx:9', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
expect(scannerGen).toBeGreaterThan(observedBeforePreview);
|
||||
return negativeOkPreview('web');
|
||||
});
|
||||
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
|
||||
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/web/update-preview')
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reconciled).toBe(false);
|
||||
expect(previewCalls).toBe(1);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).web?.checkStatus).toBe('ok');
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generation ordering for preview clear', () => {
|
||||
it('a newer scanner reservation supersedes an in-flight observation-watermark clear', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord', true, 1000, 'partial', 'half');
|
||||
const svc = ImageUpdateService.getInstance();
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord');
|
||||
const scannerGen = (svc as unknown as {
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
}).reserveStackWriteGeneration(nodeId, 'ord');
|
||||
expect(scannerGen).toBeGreaterThan(observedMem);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'ord', observedMem, observedRow)).toBe('stale');
|
||||
|
||||
const scannerCommitted = await (svc as unknown as {
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
}).withStackWriteLock(nodeId, 'ord', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord', true, Date.now(), 'ok', null);
|
||||
});
|
||||
expect(scannerCommitted).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord?.hasUpdate).toBe(true);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord?.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('equal-generation scanner reserved before observation cannot rewrite after clear', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getDefaultNode()!.id!;
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord2', true, 1000, 'partial', 'half', [
|
||||
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'partial', lastError: 'half' },
|
||||
], 1);
|
||||
const svc = ImageUpdateService.getInstance() as unknown as {
|
||||
peekStackWriteGeneration: (n: number, s: string) => number;
|
||||
reserveStackWriteGeneration: (n: number, s: string) => number;
|
||||
commitPreviewClear: (
|
||||
n: number,
|
||||
s: string,
|
||||
observedMem: number,
|
||||
observedRow: number,
|
||||
) => Promise<'cleared' | 'stale' | 'absent'>;
|
||||
withStackWriteLock: (
|
||||
n: number,
|
||||
s: string,
|
||||
g: number,
|
||||
write: () => void | Promise<void>,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
// Full scan reserved generation N before its slow registry work.
|
||||
const scannerGen = svc.reserveStackWriteGeneration(nodeId, 'ord2');
|
||||
// Preview observation sees that same watermark.
|
||||
const observedMem = svc.peekStackWriteGeneration(nodeId, 'ord2');
|
||||
const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'ord2');
|
||||
expect(observedMem).toBe(scannerGen);
|
||||
|
||||
expect(await svc.commitPreviewClear(nodeId, 'ord2', observedMem, observedRow)).toBe('cleared');
|
||||
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
|
||||
|
||||
// Delayed scanner write using the pre-observation reservation must not commit.
|
||||
const scannerCommitted = await svc.withStackWriteLock(nodeId, 'ord2', scannerGen, () => {
|
||||
db.upsertStackUpdateStatus(nodeId, 'ord2', true, Date.now(), 'ok', null, [
|
||||
{ service: 'web', image: 'web:2', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
||||
], scannerGen);
|
||||
});
|
||||
expect(scannerCommitted).toBe(false);
|
||||
expect(db.getStackUpdateDetail(nodeId).ord2).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,31 @@ import {
|
||||
computeImagePreview,
|
||||
buildSummary,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
isAuthoritativeNegativePreview,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
type ComputePreviewDeps,
|
||||
type LocalDigestInfo,
|
||||
} from '../services/UpdatePreviewService';
|
||||
import type { DigestComparisonResult } from '../services/registry-api';
|
||||
import { selectLocalRepoDigests, type DigestComparisonResult, type ParsedRef, type TagListResult } from '../services/registry-api';
|
||||
|
||||
const PLATFORM = { os: 'linux', architecture: 'amd64' };
|
||||
|
||||
function localDigest(digest: string | null): LocalDigestInfo {
|
||||
return { digest, platform: PLATFORM };
|
||||
function localDigest(
|
||||
digest: string | null | string[],
|
||||
emptyReason: LocalDigestInfo['emptyReason'] = null,
|
||||
): LocalDigestInfo {
|
||||
if (Array.isArray(digest)) return { digests: digest, platform: PLATFORM, emptyReason };
|
||||
if (digest) return { digests: [digest], platform: PLATFORM, emptyReason: null };
|
||||
return {
|
||||
digests: [],
|
||||
platform: PLATFORM,
|
||||
emptyReason: emptyReason ?? 'not_checkable',
|
||||
};
|
||||
}
|
||||
|
||||
function tagsOk(tags: string[], nextCursor?: string): TagListResult {
|
||||
return nextCursor ? { ok: true, tags, nextCursor } : { ok: true, tags };
|
||||
}
|
||||
|
||||
describe('parseSemverTag', () => {
|
||||
@@ -97,7 +113,7 @@ function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDe
|
||||
getCredentials: vi.fn().mockResolvedValue(null),
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'not configured' } satisfies DigestComparisonResult),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -107,7 +123,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
@@ -119,7 +135,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'update' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:latest', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
@@ -132,7 +148,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['27.1.4', '27.1.5', '27.2.0'])),
|
||||
});
|
||||
const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
@@ -144,7 +160,7 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '2.0.0'])),
|
||||
});
|
||||
const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
|
||||
expect(result.next_tag).toBe('2.0.0');
|
||||
@@ -155,36 +171,106 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '1.2.4']),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('1.2.4');
|
||||
expect(result.semver_bump).toBe('patch');
|
||||
// A confirmed tag-based update overrides the digest hiccup: the overall
|
||||
// check_status resolves 'ok' and check_error is not surfaced (see the
|
||||
// "treats digest error + higher tag as a confirmed update" test below).
|
||||
expect(result.check_error).toBeNull();
|
||||
});
|
||||
|
||||
it('fails soft to no-update when the comparison resolver errors and no higher tag exists', async () => {
|
||||
it('surfaces verification failure when the comparison resolver errors and no higher tag exists', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.next_tag).toBeNull();
|
||||
expect(result.semver_bump).toBe('none');
|
||||
expect(result.check_error).toBe('Registry unreachable');
|
||||
expect(result.check_status).toBe('partial');
|
||||
});
|
||||
|
||||
it('never calls the comparison resolver when no local digest is resolvable', async () => {
|
||||
it('treats digest error + higher tag as a confirmed update (ok), but keeps digest_error unmasked for the collateral-risk gate', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'Registry unreachable' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3', '1.2.4'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('1.2.4');
|
||||
expect(result.check_status).toBe('ok');
|
||||
// The tag compare independently confirmed the update, so check_status
|
||||
// masks the digest failure. digest_error must survive that masking: a
|
||||
// full-stack apply still re-pulls this image's unverified current tag.
|
||||
expect(result.digest_error).toBe('Registry unreachable');
|
||||
});
|
||||
|
||||
it('treats empty RepoDigests as not_checkable (no verification failure)', async () => {
|
||||
const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null)),
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'not_checkable')),
|
||||
compareDigest,
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(compareDigest).not.toHaveBeenCalled();
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_error).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces unresolved RepoDigests as verification failure without claiming an update', async () => {
|
||||
const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'unresolved')),
|
||||
compareDigest,
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(compareDigest).not.toHaveBeenCalled();
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_error).toBe('Could not resolve a local registry digest');
|
||||
});
|
||||
|
||||
it('wires a real unrelated-repository RepoDigest through selectLocalRepoDigests to an unresolved verification failure', async () => {
|
||||
// getLocalDigest here is not a canned stand-in: it runs the real
|
||||
// selectLocalRepoDigests against a RepoDigests array whose sole valid
|
||||
// entry belongs to a different repository, proving the removed
|
||||
// sole-unmatched-digest fallback stays removed through the actual
|
||||
// preview-computation path, not just at the registry-api unit level.
|
||||
const compareDigest = vi.fn().mockResolvedValue({ kind: 'update' });
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: async (_imageRef: string, parsed: ParsedRef) => {
|
||||
const digests = selectLocalRepoDigests(
|
||||
[`ghcr.io/other/image@sha256:${'b'.repeat(64)}`],
|
||||
parsed,
|
||||
);
|
||||
return { digests, platform: PLATFORM, emptyReason: digests.length === 0 ? 'unresolved' as const : null };
|
||||
},
|
||||
compareDigest,
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(compareDigest).not.toHaveBeenCalled();
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_error).toBe('Could not resolve a local registry digest');
|
||||
});
|
||||
|
||||
it('surfaces inspect failure as verification failure', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest(null, 'inspect_failed')),
|
||||
compareDigest: vi.fn(),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_error).toBe('Failed to inspect local image');
|
||||
});
|
||||
|
||||
it('passes the local digest, tag, and platform through to the comparison resolver', async () => {
|
||||
@@ -192,21 +278,51 @@ describe('computeImagePreview', () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest,
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
await computeImagePreview('web', 'ghcr.io/linuxserver/radarr:latest', deps);
|
||||
expect(compareDigest).toHaveBeenCalledWith('sha256:aaa', 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
|
||||
expect(compareDigest).toHaveBeenCalledWith(['sha256:aaa'], 'ghcr.io', 'linuxserver/radarr', 'latest', PLATFORM, null);
|
||||
});
|
||||
|
||||
it('forwards every local digest candidate and reports no same-tag rebuild on match', async () => {
|
||||
const STALE = `sha256:${'f'.repeat(64)}`;
|
||||
const CURRENT = `sha256:${'e'.repeat(64)}`;
|
||||
const compareDigest = vi.fn().mockResolvedValue({ kind: 'match' });
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest([STALE, CURRENT])),
|
||||
compareDigest,
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk([])),
|
||||
});
|
||||
const result = await computeImagePreview('broker', 'redis:8.8.0', deps);
|
||||
expect(compareDigest).toHaveBeenCalledWith(
|
||||
[STALE, CURRENT],
|
||||
'registry-1.docker.io',
|
||||
'library/redis',
|
||||
'8.8.0',
|
||||
PLATFORM,
|
||||
null,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
has_update: false,
|
||||
next_tag: null,
|
||||
semver_bump: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSummary', () => {
|
||||
const baseImage = (partial: Partial<Parameters<typeof buildSummary>[1][number]>) => ({
|
||||
const baseImage = (partial: Partial<Parameters<typeof buildSummary>[1][number]> = {}) => ({
|
||||
service: 'svc',
|
||||
image: 'nginx:1.0.0',
|
||||
current_tag: '1.0.0',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
check_status: 'ok' as const,
|
||||
check_error: null as string | null,
|
||||
digest_error: null as string | null,
|
||||
...partial,
|
||||
});
|
||||
|
||||
@@ -219,6 +335,41 @@ describe('buildSummary', () => {
|
||||
expect(preview.summary.blocked).toBe(true);
|
||||
expect(preview.summary.blocked_reason).toMatch(/major/i);
|
||||
expect(preview.summary.semver_bump).toBe('major');
|
||||
expect(preview.summary.verification_failed).toBe(false);
|
||||
expect(preview.summary.verification_error).toBeNull();
|
||||
});
|
||||
|
||||
it('aggregates digest verification failure without claiming a digest rebuild', () => {
|
||||
const images = [
|
||||
baseImage({
|
||||
service: 'broker',
|
||||
image: 'redis:8.8.0',
|
||||
current_tag: '8.8.0',
|
||||
check_error: 'Local image platform is unknown; cannot verify multi-arch membership',
|
||||
}),
|
||||
];
|
||||
const preview = buildSummary('app', images);
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.update_kind).toBe('none');
|
||||
expect(preview.summary.verification_failed).toBe(true);
|
||||
expect(preview.summary.verification_error).toContain('cannot verify multi-arch membership');
|
||||
});
|
||||
|
||||
it('keeps a tag update when digest verification failed on the same image', () => {
|
||||
const images = [
|
||||
baseImage({
|
||||
service: 'web',
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
next_tag: '1.0.1',
|
||||
check_error: 'Registry unreachable',
|
||||
}),
|
||||
];
|
||||
const preview = buildSummary('app', images);
|
||||
expect(preview.summary.has_update).toBe(true);
|
||||
expect(preview.summary.update_kind).toBe('tag');
|
||||
expect(preview.summary.verification_failed).toBe(true);
|
||||
expect(preview.summary.verification_error).toBe('Registry unreachable');
|
||||
});
|
||||
|
||||
it('picks first updated image as primary', () => {
|
||||
@@ -281,6 +432,15 @@ describe('buildSummary', () => {
|
||||
expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
|
||||
});
|
||||
|
||||
it('computes rollback target for an explicit docker.io/ ref the same as the bare form', () => {
|
||||
// docker.io is a Docker Hub alias normalized by parseImageRef; the rollback target
|
||||
// must render the same shortened form as the bare-name case above, not the literal host.
|
||||
const images = [
|
||||
baseImage({ service: 'db', image: 'docker.io/library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }),
|
||||
];
|
||||
expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
|
||||
});
|
||||
|
||||
it('computes rollback target for registry with port', () => {
|
||||
const images = [
|
||||
baseImage({
|
||||
@@ -334,4 +494,145 @@ describe('buildSummary', () => {
|
||||
const images = [baseImage({ service: 'clean', has_update: false })];
|
||||
expect(buildSummary('stacky', images).summary.update_kind).toBe('none');
|
||||
});
|
||||
|
||||
it('sets check_status=ok for empty and all-ok images', () => {
|
||||
expect(buildSummary('empty', []).summary.check_status).toBe('ok');
|
||||
expect(buildSummary('ok', [baseImage({ check_status: 'ok' })]).summary.check_status).toBe('ok');
|
||||
});
|
||||
|
||||
it('rolls up mixed and failed check_status', () => {
|
||||
expect(buildSummary('mixed', [
|
||||
baseImage({ service: 'a', check_status: 'ok' }),
|
||||
baseImage({ service: 'b', check_status: 'partial' }),
|
||||
]).summary.check_status).toBe('partial');
|
||||
expect(buildSummary('fail', [
|
||||
baseImage({ service: 'a', check_status: 'failed' }),
|
||||
baseImage({ service: 'b', check_status: 'failed' }),
|
||||
]).summary.check_status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview authority', () => {
|
||||
it('marks digest match + exhausted empty tags as authoritative ok with no update', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('ok');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks digest error + successful tag list with no next as partial (not authoritative-negative)', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'error', reason: 'boom' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue(tagsOk(['1.2.3'])),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat empty or not_checkable-only previews as authoritative-negative', () => {
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('empty', []))).toBe(false);
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('build', [
|
||||
{
|
||||
service: 'app',
|
||||
image: 'sha256:dead',
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'not_checkable',
|
||||
check_error: null,
|
||||
digest_error: null,
|
||||
},
|
||||
]))).toBe(false);
|
||||
});
|
||||
|
||||
it('marks digest match + tag list failure as partial for semver tags', async () => {
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
code: 'REGISTRY_UPSTREAM',
|
||||
message: 'Registry unreachable',
|
||||
}),
|
||||
}));
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
});
|
||||
|
||||
it('allows moving/non-semver tags to be authoritative-negative on digest match without tag enum', async () => {
|
||||
const listFn = vi.fn();
|
||||
const result = await computeImagePreview('web', 'nginx:latest', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).not.toHaveBeenCalled();
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('ok');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a newer tag found on a later page', async () => {
|
||||
const listFn = vi.fn()
|
||||
.mockResolvedValueOnce(tagsOk(['1.0.0', '1.0.1'], 'cursor-1'))
|
||||
.mockResolvedValueOnce(tagsOk(['1.1.0']));
|
||||
const result = await computeImagePreview('web', 'nginx:1.0.0', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).toHaveBeenCalledTimes(2);
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('1.1.0');
|
||||
expect(result.check_status).toBe('ok');
|
||||
});
|
||||
|
||||
it('treats page-cap with remaining cursor as non-authoritative for semver negatives', async () => {
|
||||
let page = 0;
|
||||
const listFn = vi.fn().mockImplementation(async () => {
|
||||
page += 1;
|
||||
return tagsOk([`1.0.${page}`], `cursor-${page}`);
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:2.0.0', makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue(localDigest('sha256:aaa')),
|
||||
compareDigest: vi.fn().mockResolvedValue({ kind: 'match' }),
|
||||
listRegistryTagsResult: listFn,
|
||||
}));
|
||||
expect(listFn).toHaveBeenCalledTimes(PREVIEW_TAG_LIST_MAX_PAGES);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.check_status).toBe('partial');
|
||||
expect(isAuthoritativeNegativePreview(buildSummary('s', [result]))).toBe(false);
|
||||
});
|
||||
|
||||
it('marks invalid refs as not_checkable', async () => {
|
||||
const result = await computeImagePreview('web', 'sha256:deadbeef', makeDeps());
|
||||
expect(result.check_status).toBe('not_checkable');
|
||||
expect(result.has_update).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAllRegistryTagsBounded', () => {
|
||||
it('returns incomplete when nextCursor remains after the page cap', async () => {
|
||||
const listFn = vi.fn().mockResolvedValue(tagsOk(['a'], 'more'));
|
||||
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null, { maxPages: 2 });
|
||||
expect(outcome.kind).toBe('incomplete');
|
||||
expect(listFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns complete when pagination exhausts', async () => {
|
||||
const listFn = vi.fn()
|
||||
.mockResolvedValueOnce(tagsOk(['a'], 'c1'))
|
||||
.mockResolvedValueOnce(tagsOk(['b']));
|
||||
const outcome = await listAllRegistryTagsBounded(listFn, 'ghcr.io', 'acme/app', null);
|
||||
expect(outcome).toEqual({ kind: 'complete', tags: ['a', 'b'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AutoHealService } from '../services/AutoHealService';
|
||||
import { HealthGateService } from '../services/HealthGateService';
|
||||
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
|
||||
import { FleetSyncRetryService } from '../services/FleetSyncRetryService';
|
||||
import { SuppressionRetractionRetryService } from '../services/SuppressionRetractionRetryService';
|
||||
import { DockerEventManager } from '../services/DockerEventManager';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
@@ -37,6 +38,9 @@ export function installShutdownHandlers(server: Server): void {
|
||||
try { HealthGateService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] HealthGateService cleanup failed:', (e as Error).message); }
|
||||
try { ServiceUpdateRecoveryService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] ServiceUpdateRecoveryService cleanup failed:', (e as Error).message); }
|
||||
try { FleetSyncRetryService.getInstance().stop(); } catch (e) { console.warn('[Shutdown] FleetSyncRetryService cleanup failed:', (e as Error).message); }
|
||||
try { SuppressionRetractionRetryService.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] SuppressionRetractionRetryService cleanup failed:', (e as Error).message);
|
||||
}
|
||||
try { DockerEventManager.getInstance().stop(); } catch (e) {
|
||||
console.warn('[Shutdown] DockerEventManager cleanup failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryS
|
||||
import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService';
|
||||
import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService';
|
||||
import { FleetSyncRetryService } from '../services/FleetSyncRetryService';
|
||||
import { SuppressionRetractionRetryService } from '../services/SuppressionRetractionRetryService';
|
||||
import { DockerEventManager } from '../services/DockerEventManager';
|
||||
import TrivyService, { sweepStaleTrivyTempDirs } from '../services/TrivyService';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
@@ -154,6 +155,7 @@ export async function startServer(server: Server): Promise<void> {
|
||||
HealthGateService.getInstance().start();
|
||||
ServiceUpdateRecoveryService.getInstance().start();
|
||||
FleetSyncRetryService.getInstance().start();
|
||||
SuppressionRetractionRetryService.getInstance().start();
|
||||
ImageUpdateService.getInstance().start();
|
||||
SchedulerService.getInstance().start();
|
||||
MfaService.getInstance().start();
|
||||
@@ -166,7 +168,11 @@ export async function startServer(server: Server): Promise<void> {
|
||||
// Drop the cached /api/meta entry on tunnel reconnect so the next
|
||||
// /api/nodes/:id/meta refetches fresh capabilities and version through
|
||||
// the live loopback bridge instead of waiting for the 3-minute TTL.
|
||||
PilotTunnelManager.getInstance().on('tunnel-up', invalidateRemoteMetaCache);
|
||||
// Also flush durable mute-replica retractions that waited for this node.
|
||||
PilotTunnelManager.getInstance().on('tunnel-up', (nodeId: number) => {
|
||||
invalidateRemoteMetaCache(nodeId);
|
||||
void SuppressionRetractionRetryService.getInstance().flushNode(nodeId);
|
||||
});
|
||||
|
||||
// Most async initializers still run in parallel. Docker event monitoring
|
||||
// is sequenced after self identity so it never classifies Sencho's own
|
||||
|
||||
@@ -9,42 +9,97 @@
|
||||
* With no argument it disables every enabled provider. The stored configuration
|
||||
* is preserved (only the enabled flag is cleared) so it can be fixed and
|
||||
* re-enabled from the UI. Written to the audit log with actor `cli`.
|
||||
*
|
||||
* When authentication_mode is sso_only and every provider is disabled (no
|
||||
* argument), this command restores local_and_sso first so the operator is
|
||||
* never left with SSO-only and zero providers. A named-provider disable that
|
||||
* would remove the last enabled provider under sso_only is rejected; use the
|
||||
* no-argument form or enableLocalLogin instead. Disabling one of several
|
||||
* providers leaves authentication_mode unchanged.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
getAuthenticationMode,
|
||||
setAuthenticationMode,
|
||||
} from '../helpers/authenticationMode';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
function restoreLocalLoginIfNeeded(db: DatabaseService): CliResult | null {
|
||||
const mode = getAuthenticationMode(db);
|
||||
if (mode !== 'sso_only') return null;
|
||||
try {
|
||||
setAuthenticationMode('local_and_sso', db);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
message: `Failed to re-enable local login before disabling SSO: ${detail}. Providers were left unchanged.`,
|
||||
};
|
||||
}
|
||||
auditCli(db, '/cli/enable-local-login', 'CLI re-enabled local password authentication before disabling SSO');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function disableSso(provider?: string): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
if (provider) {
|
||||
const config = db.getSSOConfig(provider);
|
||||
if (!config) {
|
||||
return { ok: false, message: `No SSO config found for provider: ${provider}` };
|
||||
}
|
||||
if (config.enabled !== 1) {
|
||||
return { ok: true, message: `SSO provider ${provider} is already disabled.` };
|
||||
}
|
||||
db.upsertSSOConfig(provider, false, config.config_json);
|
||||
auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`);
|
||||
return { ok: true, message: `Disabled SSO provider ${provider}. Its configuration was preserved.` };
|
||||
if (provider) {
|
||||
const config = db.getSSOConfig(provider);
|
||||
if (!config) {
|
||||
return { ok: false, message: `No SSO config found for provider: ${provider}` };
|
||||
}
|
||||
if (config.enabled !== 1) {
|
||||
return { ok: true, message: `SSO provider ${provider} is already disabled.` };
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
return { ok: true, message: 'No SSO providers are currently enabled.' };
|
||||
const mode = getAuthenticationMode(db);
|
||||
if (mode === 'sso_only') {
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 1 && enabled[0].provider === provider) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`Cannot disable the last SSO provider while SSO-only mode is active. ` +
|
||||
`Run without a provider argument, or run enableLocalLogin first.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const config of enabled) {
|
||||
db.upsertSSOConfig(config.provider, false, config.config_json);
|
||||
}
|
||||
const names = enabled.map(c => c.provider).join(', ');
|
||||
auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`);
|
||||
return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` };
|
||||
|
||||
// Named disable leaves authentication_mode unchanged (including sso_only).
|
||||
db.upsertSSOConfig(provider, false, config.config_json);
|
||||
auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`);
|
||||
const modeNote =
|
||||
mode === 'sso_only'
|
||||
? ' Authentication mode remains SSO only; remaining providers stay available.'
|
||||
: '';
|
||||
return {
|
||||
ok: true,
|
||||
message: `Disabled SSO provider ${provider}. Its configuration was preserved.${modeNote}`,
|
||||
};
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
const modeError = restoreLocalLoginIfNeeded(db);
|
||||
if (modeError) return modeError;
|
||||
return { ok: true, message: 'No SSO providers are currently enabled.' };
|
||||
}
|
||||
|
||||
const modeError = restoreLocalLoginIfNeeded(db);
|
||||
if (modeError) return modeError;
|
||||
|
||||
for (const config of enabled) {
|
||||
db.upsertSSOConfig(config.provider, false, config.config_json);
|
||||
}
|
||||
const names = enabled.map(c => c.provider).join(', ');
|
||||
auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`);
|
||||
return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` };
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(disableSso(process.argv[2]));
|
||||
exitWith(disableSso(process.argv[2]));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Emergency CLI: re-enable local password authentication after SSO-only mode
|
||||
* locks out interactive password login (for example when the identity provider
|
||||
* is unavailable).
|
||||
*
|
||||
* Run via:
|
||||
* docker compose exec sencho node dist/cli/enableLocalLogin.js
|
||||
*
|
||||
* Requires local shell or Docker-host access. Does not contact the identity
|
||||
* provider. Written to the audit log with actor `cli`. Restart Sencho after
|
||||
* running so the in-process settings cache picks up the change.
|
||||
*/
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import {
|
||||
getAuthenticationMode,
|
||||
setAuthenticationMode,
|
||||
} from '../helpers/authenticationMode';
|
||||
import { auditCli, exitWith, type CliResult } from './_shared';
|
||||
|
||||
export function enableLocalLogin(): CliResult {
|
||||
const db = DatabaseService.getInstance();
|
||||
const current = getAuthenticationMode(db);
|
||||
if (current === 'local_and_sso') {
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
'Local password authentication is already enabled (authentication_mode=local_and_sso).',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
setAuthenticationMode('local_and_sso', db);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return { ok: false, message: `Failed to re-enable local login: ${detail}` };
|
||||
}
|
||||
|
||||
auditCli(db, '/cli/enable-local-login', 'CLI re-enabled local password authentication');
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
'Local login re-enabled. Restart Sencho for the change to take effect: docker compose restart sencho',
|
||||
};
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
exitWith(enableLocalLogin());
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
export const AUTHENTICATION_MODES = ['local_and_sso', 'sso_only'] as const;
|
||||
export type AuthenticationMode = (typeof AUTHENTICATION_MODES)[number];
|
||||
|
||||
export const AUTHENTICATION_MODE_KEY = 'authentication_mode';
|
||||
export const DEFAULT_AUTHENTICATION_MODE: AuthenticationMode = 'local_and_sso';
|
||||
|
||||
/** Read the cached global setting; missing or unknown values default to local_and_sso. */
|
||||
export function getAuthenticationMode(db: DatabaseService = DatabaseService.getInstance()): AuthenticationMode {
|
||||
const raw = db.getGlobalSettings()[AUTHENTICATION_MODE_KEY];
|
||||
if (raw === 'sso_only') return 'sso_only';
|
||||
return DEFAULT_AUTHENTICATION_MODE;
|
||||
}
|
||||
|
||||
export function isLocalLoginEnabled(db: DatabaseService = DatabaseService.getInstance()): boolean {
|
||||
return getAuthenticationMode(db) !== 'sso_only';
|
||||
}
|
||||
|
||||
export function setAuthenticationMode(
|
||||
mode: AuthenticationMode,
|
||||
db: DatabaseService = DatabaseService.getInstance(),
|
||||
): void {
|
||||
db.updateGlobalSetting(AUTHENTICATION_MODE_KEY, mode);
|
||||
}
|
||||
|
||||
export function isAuthenticationMode(value: unknown): value is AuthenticationMode {
|
||||
return value === 'local_and_sso' || value === 'sso_only';
|
||||
}
|
||||
|
||||
/** True when disabling/deleting this enabled provider would leave zero providers under sso_only. */
|
||||
export function wouldRemoveLastProvider(provider: string, currentlyEnabled: boolean): boolean {
|
||||
if (!currentlyEnabled) return false;
|
||||
if (isLocalLoginEnabled()) return false;
|
||||
const enabled = DatabaseService.getInstance().getEnabledSSOConfigs();
|
||||
return enabled.length === 1 && enabled[0].provider === provider;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ImageCheckResult } from '../services/ImageUpdateService';
|
||||
|
||||
/** Accumulator for auto-update image checks (digest vs tag-only vs error). */
|
||||
export interface AutoUpdateDigestGateState {
|
||||
hasDigestUpdate: boolean;
|
||||
hasTagOnlyUpdate: boolean;
|
||||
updatedImages: string[];
|
||||
checkErrors: string[];
|
||||
}
|
||||
|
||||
export function createAutoUpdateDigestGateState(): AutoUpdateDigestGateState {
|
||||
return {
|
||||
hasDigestUpdate: false,
|
||||
hasTagOnlyUpdate: false,
|
||||
updatedImages: [],
|
||||
checkErrors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one image check into the digest gate. Only digest drift is Compose-
|
||||
* actionable for auto-update; tag bumps stay advisory.
|
||||
*/
|
||||
export function recordAutoUpdateImageCheck(
|
||||
state: AutoUpdateDigestGateState,
|
||||
imageRef: string,
|
||||
result: ImageCheckResult,
|
||||
): void {
|
||||
if (result.digestUpdate) {
|
||||
state.hasDigestUpdate = true;
|
||||
state.updatedImages.push(imageRef);
|
||||
return;
|
||||
}
|
||||
if (result.tagUpdate) {
|
||||
state.hasTagOnlyUpdate = true;
|
||||
return;
|
||||
}
|
||||
if (result.error || result.checkStatus === 'failed' || result.checkStatus === 'partial') {
|
||||
state.checkErrors.push(result.error ?? 'Update check incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator message when a sibling image check failed and a full-stack Compose
|
||||
* update must not run (it would pull/recreate the unverified image as
|
||||
* collateral). Null when digest apply may proceed.
|
||||
*/
|
||||
export function messageWhenDigestApplyBlockedByCheckErrors(
|
||||
stackName: string,
|
||||
state: Pick<AutoUpdateDigestGateState, 'hasDigestUpdate' | 'checkErrors'>,
|
||||
): string | null {
|
||||
if (!state.hasDigestUpdate || state.checkErrors.length === 0) return null;
|
||||
return `Stack "${stackName}": WARNING - digest update available but ${state.checkErrors.length} image check(s) failed; skipped auto-update (${state.checkErrors.join('; ')}).`;
|
||||
}
|
||||
|
||||
/** Operator message when no digest-actionable update was found. */
|
||||
export function messageWhenNoDigestUpdate(
|
||||
stackName: string,
|
||||
state: Pick<AutoUpdateDigestGateState, 'hasTagOnlyUpdate' | 'checkErrors'>,
|
||||
imageRefCount: number,
|
||||
): string {
|
||||
if (state.hasTagOnlyUpdate) {
|
||||
const errNote = state.checkErrors.length > 0
|
||||
? ` (${state.checkErrors.length} check(s) failed)`
|
||||
: '';
|
||||
return `Stack "${stackName}": newer tag available but Compose pin unchanged; skipped auto-update${errNote}.`;
|
||||
}
|
||||
if (state.checkErrors.length > 0 && state.checkErrors.length === imageRefCount) {
|
||||
return `Stack "${stackName}": WARNING - all image checks failed (${state.checkErrors.join('; ')}). Unable to determine update status.`;
|
||||
}
|
||||
if (state.checkErrors.length > 0) {
|
||||
return `Stack "${stackName}": all reachable images up to date (${state.checkErrors.length} check(s) failed).`;
|
||||
}
|
||||
return `Stack "${stackName}": all images up to date.`;
|
||||
}
|
||||
@@ -23,6 +23,11 @@ export const MAX_ASSIGNMENTS = 1000;
|
||||
// Session cookies
|
||||
export const COOKIE_NAME = 'sencho_token';
|
||||
export const SESSION_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
export const REMEMBER_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, "stay signed in"
|
||||
// Sliding-refresh window: a user-session token with less than this much life
|
||||
// left gets silently reissued with a fresh full TTL, so continued activity
|
||||
// never runs into the hard expiry. See middleware/auth.ts::authMiddleware.
|
||||
export const SESSION_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
|
||||
export const MFA_PENDING_SCOPE = 'mfa_pending';
|
||||
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { Request } from 'express';
|
||||
import { SESSION_COOKIE_MAX_AGE_MS } from './constants';
|
||||
|
||||
/** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */
|
||||
export const isSecureRequest = (req: Request): boolean => {
|
||||
return req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||||
};
|
||||
|
||||
/** Cookie options derived from the current request (secure flag follows the connection). */
|
||||
/**
|
||||
* Cookie options derived from the current request (secure flag follows the
|
||||
* connection). Lifetime is deliberately not included: each caller sets its own
|
||||
* `maxAge` (session cookies vary between the default and "stay signed in", the
|
||||
* MFA-pending cookie is minutes long), so a shared default here would only ever
|
||||
* be overridden or misread.
|
||||
*/
|
||||
export const getCookieOptions = (req: Request) => ({
|
||||
httpOnly: true,
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: 'strict' as const,
|
||||
maxAge: SESSION_COOKIE_MAX_AGE_MS,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
/** Hub aggregation cache for GET /api/image-updates/fleet. */
|
||||
export const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
|
||||
|
||||
/**
|
||||
* Drop the hub fleet-updates aggregation. Generation-aware via CacheService:
|
||||
* an in-flight getOrFetch started before this call cannot commit afterward.
|
||||
*/
|
||||
export function invalidateFleetUpdateCache(): void {
|
||||
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
|
||||
}
|
||||
|
||||
function pathWithoutQuery(pathAfterApi: string): string {
|
||||
return pathAfterApi.split('?')[0] ?? pathAfterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathAfterApi` is a full-stack update route
|
||||
* (`/stacks/:name/update`), not a service-scoped update/restore.
|
||||
* `pathAfterApi` is the Express path after the `/api` mount strip
|
||||
* (for example `/stacks/paperless/update`).
|
||||
*/
|
||||
export function isFullStackUpdatePath(pathAfterApi: string): boolean {
|
||||
return /^\/stacks\/[^/]+\/update\/?$/.test(pathWithoutQuery(pathAfterApi));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `pathAfterApi` is the stack update-preview route
|
||||
* (`/stacks/:name/update-preview`).
|
||||
*/
|
||||
export function isUpdatePreviewPath(pathAfterApi: string): boolean {
|
||||
return /^\/stacks\/[^/]+\/update-preview\/?$/.test(pathWithoutQuery(pathAfterApi));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Structural healthcheck presence classification for Compose YAML objects and
|
||||
* Docker inspect `Config.Healthcheck.Test` arrays. Returns enums/booleans only;
|
||||
* never retains or returns Test command text (commands can carry secrets).
|
||||
*/
|
||||
|
||||
export type ComposeHealthcheckClass = 'active' | 'disabled' | 'absent';
|
||||
|
||||
/**
|
||||
* Classify a Compose `healthcheck:` value from the rendered effective model.
|
||||
* `disable: true` and `test: NONE` / `["NONE"]` are explicit disablement.
|
||||
* An empty object, `disable: false` alone, or timing-only fields without a
|
||||
* `test` are absent (fall through to runtime/image evidence), not active.
|
||||
*/
|
||||
export function classifyComposeHealthcheck(healthcheck: unknown): ComposeHealthcheckClass {
|
||||
if (healthcheck == null) return 'absent';
|
||||
if (typeof healthcheck !== 'object' || Array.isArray(healthcheck)) return 'absent';
|
||||
const hc = healthcheck as Record<string, unknown>;
|
||||
if (hc.disable === true) return 'disabled';
|
||||
if (isNoneTest(hc.test)) return 'disabled';
|
||||
if (hasActiveTest(hc.test)) return 'active';
|
||||
return 'absent';
|
||||
}
|
||||
|
||||
/** True when the Compose healthcheck is an active (non-disabled) declaration. */
|
||||
export function isComposeHealthcheckActive(healthcheck: unknown): boolean {
|
||||
return classifyComposeHealthcheck(healthcheck) === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Docker's effective healthcheck Test is present and active.
|
||||
* Empty / missing / `NONE` / `["NONE"]` are inactive.
|
||||
*/
|
||||
export function isDockerHealthcheckActive(test: unknown): boolean {
|
||||
return hasActiveTest(test);
|
||||
}
|
||||
|
||||
/** True when `test` is a non-empty, non-NONE healthcheck command. */
|
||||
function hasActiveTest(test: unknown): boolean {
|
||||
if (test == null) return false;
|
||||
if (typeof test === 'string') {
|
||||
const trimmed = test.trim();
|
||||
return trimmed.length > 0 && !isNoneToken(trimmed);
|
||||
}
|
||||
if (!Array.isArray(test) || test.length === 0) return false;
|
||||
if (isNoneTest(test)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNoneTest(test: unknown): boolean {
|
||||
if (typeof test === 'string') return isNoneToken(test);
|
||||
if (!Array.isArray(test) || test.length === 0) return false;
|
||||
return test.length === 1 && typeof test[0] === 'string' && isNoneToken(test[0]);
|
||||
}
|
||||
|
||||
function isNoneToken(value: string): boolean {
|
||||
return value.trim().toUpperCase() === 'NONE';
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService';
|
||||
import { DatabaseService, type NotificationSuppressionRule, type NotificationSuppressionRetraction, type NotificationSuppressionRetractionKind, type Node } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY,
|
||||
NOTIFICATION_SUPPRESSION_REPLICA_RETRACTION_CAPABILITY,
|
||||
} from '../services/CapabilityRegistry';
|
||||
import { remoteAdvertisesCapability } from './remoteCapabilities';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -20,6 +21,13 @@ function buildRemoteHeaders(apiToken: string): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function retractionFor(
|
||||
kind: NotificationSuppressionRetractionKind,
|
||||
rule: Pick<NotificationSuppressionRule, 'updated_at'>,
|
||||
): NotificationSuppressionRetraction {
|
||||
return { kind, source_updated_at: rule.updated_at };
|
||||
}
|
||||
|
||||
/** Hub node ids that should receive a replica for this rule (before wire identity normalize). */
|
||||
export function replicationTargetIds(rule: NotificationSuppressionRule): number[] {
|
||||
return replicationTargets(rule).map((n) => n.id);
|
||||
@@ -35,46 +43,171 @@ export function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
||||
return remotes;
|
||||
}
|
||||
|
||||
/** Every registered remote: permanent deletes must reach historical replicas, not only current scope. */
|
||||
export function allRemoteNodes(): Node[] {
|
||||
return DatabaseService.getInstance().getNodes().filter((n) => n.type === 'remote');
|
||||
}
|
||||
|
||||
function replicaPayload(rule: NotificationSuppressionRule): NotificationSuppressionRule {
|
||||
return { ...rule, node_id: null };
|
||||
}
|
||||
|
||||
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target?.apiUrl) {
|
||||
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
|
||||
return;
|
||||
}
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
|
||||
method: 'POST',
|
||||
headers: buildRemoteHeaders(target.apiToken),
|
||||
body: JSON.stringify({ rule: replicaPayload(rule) }),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
||||
type RetractionSupport = 'supported' | 'unsupported' | 'unreachable';
|
||||
|
||||
async function probeRetractionSupport(nodeId: number): Promise<RetractionSupport> {
|
||||
try {
|
||||
const meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId);
|
||||
// fetchMetaForNode returns OFFLINE_META (online:false, capabilities:[]) on
|
||||
// transport failure instead of throwing; treat that as unreachable.
|
||||
if (meta.online === false) return 'unreachable';
|
||||
return meta.capabilities.includes(NOTIFICATION_SUPPRESSION_REPLICA_RETRACTION_CAPABILITY)
|
||||
? 'supported'
|
||||
: 'unsupported';
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[SuppressionSync] Retraction capability probe failed for node ${nodeId}; treating as unreachable:`,
|
||||
getErrorMessage(err, 'unknown'),
|
||||
);
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
|
||||
function enqueuePending(
|
||||
node: Node,
|
||||
ruleId: number,
|
||||
retraction: NotificationSuppressionRetraction,
|
||||
lastError: string,
|
||||
): void {
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionPendingRetraction({
|
||||
rule_id: ruleId,
|
||||
node_id: node.id,
|
||||
kind: retraction.kind,
|
||||
source_updated_at: retraction.source_updated_at,
|
||||
last_error: lastError,
|
||||
});
|
||||
}
|
||||
|
||||
function clearPending(nodeId: number, ruleId: number): void {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionPendingRetraction(ruleId, nodeId);
|
||||
}
|
||||
|
||||
function resolveRemoteApi(node: Node): { baseUrl: string; apiToken: string } | null {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target?.apiUrl) {
|
||||
throw new Error(`no proxy target for node "${node.name}" (id=${node.id})`);
|
||||
if (!target?.apiUrl) return null;
|
||||
return { baseUrl: target.apiUrl.replace(/\/$/, ''), apiToken: target.apiToken };
|
||||
}
|
||||
|
||||
/** Non-2xx (including opaque 404) is always failure; never treat missing routes as applied. */
|
||||
async function throwHttpFailure(res: Response): Promise<never> {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
|
||||
/** Prefer JSON outcome; fall back to applied for pre-outcome remotes (bare 2xx). */
|
||||
async function readOutcome(res: Response): Promise<string> {
|
||||
try {
|
||||
const json = (await res.json()) as { outcome?: string };
|
||||
return typeof json.outcome === 'string' ? json.outcome : 'applied';
|
||||
} catch {
|
||||
return 'applied';
|
||||
}
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildRemoteHeaders(target.apiToken),
|
||||
}
|
||||
|
||||
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const remote = resolveRemoteApi(node);
|
||||
if (!remote) {
|
||||
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${remote.baseUrl}/api/notification-suppression-rules/replica`, {
|
||||
method: 'POST',
|
||||
headers: buildRemoteHeaders(remote.apiToken),
|
||||
body: JSON.stringify({ rule: replicaPayload(rule) }),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
||||
if (!res.ok) await throwHttpFailure(res);
|
||||
const outcome = await readOutcome(res);
|
||||
if (outcome !== 'applied') {
|
||||
throw new Error(`replica POST outcome=${outcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a replica DELETE. On transport failure, durable-queues the retraction.
|
||||
* Clears pending only when the remote reports outcome=applied (not ignored_stale).
|
||||
*/
|
||||
export async function deleteRuleOnNode(
|
||||
node: Node,
|
||||
ruleId: number,
|
||||
retraction: NotificationSuppressionRetraction,
|
||||
): Promise<{ outcome: string }> {
|
||||
const remote = resolveRemoteApi(node);
|
||||
if (!remote) {
|
||||
const err = `no proxy target for node "${node.name}" (id=${node.id})`;
|
||||
enqueuePending(node, ruleId, retraction, err);
|
||||
throw new Error(err);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${remote.baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildRemoteHeaders(remote.apiToken),
|
||||
body: JSON.stringify(retraction),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) await throwHttpFailure(res);
|
||||
const outcome = await readOutcome(res);
|
||||
if (outcome !== 'applied') {
|
||||
// ignored_stale and any other non-applied outcome keep the outbox row.
|
||||
const reason =
|
||||
outcome === 'ignored_stale'
|
||||
? 'remote ignored_stale; rule still present'
|
||||
: `remote DELETE outcome=${outcome}`;
|
||||
enqueuePending(node, ruleId, retraction, reason);
|
||||
return { outcome };
|
||||
}
|
||||
clearPending(node.id, ruleId);
|
||||
return { outcome };
|
||||
} catch (err) {
|
||||
try {
|
||||
enqueuePending(node, ruleId, retraction, getErrorMessage(err, String(err)));
|
||||
} catch (enqueueErr) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to durable-queue retract rule=${ruleId} node=${node.id}:`,
|
||||
getErrorMessage(enqueueErr, String(enqueueErr)),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recoverable DELETE only when the remote advertises versioned retractions.
|
||||
* Otherwise enqueue pending and do not bare-delete on incompatible remotes.
|
||||
* @returns applied when DELETE was accepted; deferred when queued without sending
|
||||
* or when the remote ignored a stale watermark.
|
||||
*/
|
||||
async function deliverRecoverableDelete(
|
||||
node: Node,
|
||||
ruleId: number,
|
||||
retraction: NotificationSuppressionRetraction,
|
||||
context: string,
|
||||
): Promise<'applied' | 'deferred'> {
|
||||
const support = await probeRetractionSupport(node.id);
|
||||
if (support !== 'supported') {
|
||||
const reason =
|
||||
support === 'unsupported'
|
||||
? 'remote lacks notification-suppression-replica-retraction; not sending recoverable DELETE'
|
||||
: 'remote unreachable for retraction capability probe; not sending recoverable DELETE';
|
||||
enqueuePending(node, ruleId, retraction, reason);
|
||||
console.warn(
|
||||
`[SuppressionSync] ${context} on node "${node.name}" (id=${node.id}): ${reason}; queued pending retraction`,
|
||||
);
|
||||
return 'deferred';
|
||||
}
|
||||
const { outcome } = await deleteRuleOnNode(node, ruleId, retraction);
|
||||
return outcome === 'applied' ? 'applied' : 'deferred';
|
||||
}
|
||||
|
||||
async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const supportsSchedule = await remoteAdvertisesCapability(
|
||||
node.id,
|
||||
@@ -84,36 +217,46 @@ async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionR
|
||||
await pushRuleToNode(node, rule);
|
||||
return;
|
||||
}
|
||||
// Probe false means unsupported OR unreachable. Never POST a scheduled rule
|
||||
// through the legacy contract (older remotes would mute all day). Attempt DELETE;
|
||||
// only claim cleanup when DELETE succeeds.
|
||||
// Never POST a scheduled rule through the legacy contract. Soft-cleanup DELETE
|
||||
// requires versioned retraction support so incompatible remotes are not bare-deleted.
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
console.warn(
|
||||
`[SuppressionSync] Scheduled rule ${rule.id} not applied on node "${node.name}" (id=${node.id}): ` +
|
||||
`capability unsupported-or-unreachable; DELETE succeeded and replica was removed`,
|
||||
const result = await deliverRecoverableDelete(
|
||||
node,
|
||||
rule.id,
|
||||
retractionFor('recoverable', rule),
|
||||
`Scheduled rule ${rule.id} not applied`,
|
||||
);
|
||||
if (result === 'applied') {
|
||||
console.warn(
|
||||
`[SuppressionSync] Scheduled rule ${rule.id} not applied on node "${node.name}" (id=${node.id}): ` +
|
||||
`schedule unsupported-or-unreachable; recoverable DELETE applied`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Scheduled rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
|
||||
`capability unsupported-or-unreachable and DELETE failed (${getErrorMessage(err, String(err))}). ` +
|
||||
`Prior replica may remain until connectivity returns and the rule is re-saved`,
|
||||
`recoverable DELETE failed (${getErrorMessage(err, String(err))})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupInvalidScheduleReplica(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
// Never POST an invalid schedule (would mute all day on remotes that ignore the field).
|
||||
// Attempt DELETE so a prior valid/unscheduled replica cannot keep muting.
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
console.warn(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: replica removed on node "${node.name}" (id=${node.id}); not posting`,
|
||||
const result = await deliverRecoverableDelete(
|
||||
node,
|
||||
rule.id,
|
||||
retractionFor('recoverable', rule),
|
||||
`Corrupt schedule on rule ${rule.id}`,
|
||||
);
|
||||
if (result === 'applied') {
|
||||
console.warn(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: recoverable DELETE applied on node "${node.name}" (id=${node.id}); not posting`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
|
||||
`DELETE failed (${getErrorMessage(err, String(err))}). Prior replica may remain until connectivity returns`,
|
||||
`DELETE failed (${getErrorMessage(err, String(err))})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -160,13 +303,19 @@ export function syncSuppressionRuleUpdateToFleet(
|
||||
const newIds = new Set(replicationTargetIds(updated));
|
||||
const db = DatabaseService.getInstance();
|
||||
const staleIds = [...oldIds].filter((id) => !newIds.has(id));
|
||||
const staleRetraction = retractionFor('recoverable', updated);
|
||||
|
||||
void Promise.allSettled([
|
||||
...staleIds.map(async (id) => {
|
||||
const node = db.getNode(id);
|
||||
if (!node || node.type !== 'remote') return;
|
||||
try {
|
||||
await deleteRuleOnNode(node, previous.id);
|
||||
await deliverRecoverableDelete(
|
||||
node,
|
||||
previous.id,
|
||||
staleRetraction,
|
||||
`Stale-target retract for rule ${previous.id}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to delete stale rule ${previous.id} on node "${node.name}":`,
|
||||
@@ -187,14 +336,18 @@ export function syncSuppressionRuleUpdateToFleet(
|
||||
]);
|
||||
}
|
||||
|
||||
/** Best-effort delete of a replicated rule on fleet nodes. */
|
||||
/**
|
||||
* Authoritative delete: fan out permanent retraction to every known remote
|
||||
* (not only current scope), and durable-queue failures for retry.
|
||||
*/
|
||||
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
|
||||
const targets = replicationTargets(rule);
|
||||
const targets = allRemoteNodes();
|
||||
if (targets.length === 0) return;
|
||||
const retraction = retractionFor('permanent', rule);
|
||||
void Promise.allSettled(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
await deleteRuleOnNode(node, rule.id, retraction);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`,
|
||||
@@ -204,3 +357,40 @@ export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry durable pending retractions for one node (tunnel-up / reconnect) or all.
|
||||
* Recoverable rows still require the remote to advertise retraction support.
|
||||
*/
|
||||
export async function flushPendingSuppressionRetractions(nodeId?: number): Promise<void> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const pending = db.listNotificationSuppressionPendingRetractions(nodeId);
|
||||
for (const row of pending) {
|
||||
const node = db.getNode(row.node_id);
|
||||
if (!node || node.type !== 'remote') {
|
||||
db.deleteNotificationSuppressionPendingRetraction(row.rule_id, row.node_id);
|
||||
continue;
|
||||
}
|
||||
const retraction: NotificationSuppressionRetraction = {
|
||||
kind: row.kind,
|
||||
source_updated_at: row.source_updated_at,
|
||||
};
|
||||
try {
|
||||
if (retraction.kind === 'recoverable') {
|
||||
await deliverRecoverableDelete(
|
||||
node,
|
||||
row.rule_id,
|
||||
retraction,
|
||||
`Pending recoverable retract for rule ${row.rule_id}`,
|
||||
);
|
||||
} else {
|
||||
await deleteRuleOnNode(node, row.rule_id, retraction);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Pending retract retry failed rule=${row.rule_id} node=${node.name}:`,
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { autoHealRouter } from './routes/autoHeal';
|
||||
import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications';
|
||||
import { consoleRouter } from './routes/console';
|
||||
import { ssoConfigRouter } from './routes/ssoConfig';
|
||||
import { authModeRouter } from './routes/authMode';
|
||||
import { registriesRouter } from './routes/registries';
|
||||
import { systemMaintenanceRouter } from './routes/systemMaintenance';
|
||||
import { volumesRouter } from './routes/volumes';
|
||||
@@ -139,6 +140,7 @@ app.use('/api/notification-routes', notificationRoutesRouter);
|
||||
app.use('/api/notification-suppression-rules', notificationSuppressionRouter);
|
||||
app.use('/api/system', consoleRouter);
|
||||
app.use('/api/sso/config', ssoConfigRouter);
|
||||
app.use('/api/sso/auth-mode', authModeRouter);
|
||||
app.use('/api/registries', registriesRouter);
|
||||
app.use('/api/system', systemMaintenanceRouter);
|
||||
app.use('/api/volumes', volumesRouter);
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
MFA_PENDING_COOKIE_NAME,
|
||||
MFA_PENDING_SCOPE,
|
||||
MFA_PENDING_TTL_MS,
|
||||
SESSION_COOKIE_MAX_AGE_MS,
|
||||
REMEMBER_SESSION_MAX_AGE_MS,
|
||||
SESSION_REFRESH_THRESHOLD_MS,
|
||||
} from '../helpers/constants';
|
||||
import { getCookieOptions } from '../helpers/cookies';
|
||||
import { looksLikeApiToken } from '../utils/apiTokenFormat';
|
||||
@@ -79,7 +82,7 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean; remember?: boolean; exp?: number };
|
||||
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
|
||||
|
||||
@@ -161,8 +164,11 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
}
|
||||
|
||||
// Token version check: rejects sessions after password change, role change, or admin reset.
|
||||
// Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h.
|
||||
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
|
||||
// A token without a tv claim is a pre-migration legacy token minted when
|
||||
// token_version was 1. Default to 1 on decode so the token is rejected only
|
||||
// when a security event (password change, MFA reset, role change, admin
|
||||
// invalidation) has actually bumped the version since it was issued.
|
||||
if (dbUser.token_version !== (decoded.tv ?? 1)) {
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version);
|
||||
console.log('[Auth] Session rejected: token version mismatch for:', decoded.username);
|
||||
res.status(401).json({ error: 'Session invalidated. Please log in again.' });
|
||||
@@ -173,6 +179,28 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
|
||||
// Use the DB role (not the JWT role) so role changes take effect immediately
|
||||
req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id };
|
||||
const remember = decoded.remember === true;
|
||||
req.sessionRemember = remember;
|
||||
|
||||
// Sliding refresh: a session nearing its expiry gets silently reissued with
|
||||
// a fresh full TTL (matching whichever TTL, 24h or "stay signed in" 30d, the
|
||||
// original login chose), so an actively-used tab never runs into the hard
|
||||
// cutoff. Disabled via the session_sliding_refresh setting (default on) for
|
||||
// admins who want a strict absolute session ceiling. This is a best-effort
|
||||
// optimization on an already-authenticated request, so its own try/catch
|
||||
// keeps a reissue failure from being reported as an invalid token.
|
||||
if (settings.session_sliding_refresh !== '0' && typeof decoded.exp === 'number') {
|
||||
const remainingMs = decoded.exp * 1000 - Date.now();
|
||||
const shouldRefresh = remainingMs < SESSION_REFRESH_THRESHOLD_MS;
|
||||
if (isDebugEnabled()) console.log('[Auth:diag] Sliding refresh check:', decoded.username, 'remainingMs:', remainingMs, 'refreshed:', shouldRefresh);
|
||||
if (shouldRefresh) {
|
||||
try {
|
||||
issueSessionCookie(res, req, dbUser, jwtSecret, remember);
|
||||
} catch (refreshErr) {
|
||||
console.error('[Auth] Sliding session refresh failed for', dbUser.username, getErrorMessage(refreshErr, 'unknown'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
@@ -182,19 +210,48 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
|
||||
}
|
||||
};
|
||||
|
||||
/** Sign a session JWT and set it as an httpOnly cookie. */
|
||||
/**
|
||||
* Drop any already-queued `Set-Cookie` entry for `name` on this response
|
||||
* before a caller appends a new one. `res.cookie()` appends rather than
|
||||
* replaces, so a request path that issues the same cookie twice (the
|
||||
* sliding refresh in `authMiddleware` followed by a token-bump reissue in
|
||||
* the same response, e.g. a password change inside the refresh window)
|
||||
* would otherwise send two `Set-Cookie` headers for one name: the first
|
||||
* carrying an already-superseded `token_version`. Browsers apply the last
|
||||
* one, but any other client taking the first would treat itself as
|
||||
* signed out on its very next request. Deduping keeps exactly one, correct
|
||||
* cookie in the response regardless of call order.
|
||||
*/
|
||||
function dropQueuedCookie(res: Response, name: string): void {
|
||||
const existing = res.getHeader('Set-Cookie');
|
||||
if (!existing) return;
|
||||
const entries = Array.isArray(existing) ? existing : [String(existing)];
|
||||
const filtered = entries.filter((entry) => !entry.startsWith(`${name}=`));
|
||||
if (filtered.length !== entries.length) res.setHeader('Set-Cookie', filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a session JWT and set it as an httpOnly cookie. `remember` extends the
|
||||
* session to `REMEMBER_SESSION_MAX_AGE_MS` (30 days, "stay signed in") instead
|
||||
* of the default `SESSION_COOKIE_MAX_AGE_MS` (24h); the choice is carried in
|
||||
* the token's `remember` claim so a later sliding refresh (authMiddleware) or
|
||||
* post-token-bump reissue (reissueSessionAfterTokenBump) reapplies the same TTL.
|
||||
*/
|
||||
export function issueSessionCookie(
|
||||
res: Response,
|
||||
req: Request,
|
||||
user: { username: string; role: string; token_version: number },
|
||||
jwtSecret: string,
|
||||
remember = false,
|
||||
): void {
|
||||
const maxAgeMs = remember ? REMEMBER_SESSION_MAX_AGE_MS : SESSION_COOKIE_MAX_AGE_MS;
|
||||
const token = jwt.sign(
|
||||
{ username: user.username, role: user.role, tv: user.token_version },
|
||||
{ username: user.username, role: user.role, tv: user.token_version, remember },
|
||||
jwtSecret,
|
||||
{ expiresIn: '24h' },
|
||||
{ expiresIn: Math.floor(maxAgeMs / 1000) },
|
||||
);
|
||||
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
|
||||
dropQueuedCookie(res, COOKIE_NAME);
|
||||
res.cookie(COOKIE_NAME, token, { ...getCookieOptions(req), maxAge: maxAgeMs });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,10 +266,10 @@ export function issueMfaPendingCookie(
|
||||
req: Request,
|
||||
user: { id: number; username: string },
|
||||
jwtSecret: string,
|
||||
opts: { sso?: boolean } = {},
|
||||
opts: { sso?: boolean; remember?: boolean } = {},
|
||||
): void {
|
||||
const token = jwt.sign(
|
||||
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true },
|
||||
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true, remember: opts.remember === true },
|
||||
jwtSecret,
|
||||
{ expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) },
|
||||
);
|
||||
@@ -239,6 +296,6 @@ export function reissueSessionAfterTokenBump(req: Request, res: Response, userId
|
||||
const refreshed = db.getUserById(userId);
|
||||
const settings = db.getGlobalSettings();
|
||||
if (refreshed && settings.auth_jwt_secret) {
|
||||
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
|
||||
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret, req.sessionRemember === true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DatabaseService } from '../services/DatabaseService';
|
||||
import { redactSensitiveText } from '../utils/safeLog';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming';
|
||||
import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache';
|
||||
|
||||
/**
|
||||
* Per-request hop timing for the critical hydration GETs, kept off the Request
|
||||
@@ -175,6 +176,18 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
timing.upstreamStatus = proxyRes.statusCode;
|
||||
timing.ttfbMs = Date.now() - timing.startedAt;
|
||||
}
|
||||
// Hub fleet aggregation is local-only. A successful remote full-stack
|
||||
// Apply or update-preview reconcile must drop the hub cache so the next
|
||||
// fleet poll does not revive a verified-cleared card from a stale entry.
|
||||
const status = proxyRes.statusCode ?? 0;
|
||||
if (
|
||||
req.method === 'POST'
|
||||
&& status >= 200
|
||||
&& status < 300
|
||||
&& (isFullStackUpdatePath(req.path) || isUpdatePreviewPath(req.path))
|
||||
) {
|
||||
invalidateFleetUpdateCache();
|
||||
}
|
||||
},
|
||||
error: (err, req, proxyRes) => {
|
||||
// Finalize the hop timing with an error outcome before the existing
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { getAuthenticationMode, isLocalLoginEnabled } from '../helpers/authenticationMode';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
@@ -34,6 +35,8 @@ authRouter.get('/status', async (req: Request, res: Response): Promise<void> =>
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
const authenticationMode = getAuthenticationMode();
|
||||
const localLoginEnabled = authenticationMode !== 'sso_only';
|
||||
|
||||
let mfaPending = false;
|
||||
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
||||
@@ -46,10 +49,10 @@ authRouter.get('/status', async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ needsSetup, mfaPending });
|
||||
res.json({ needsSetup, mfaPending, localLoginEnabled, authenticationMode });
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.json({ needsSetup: true, mfaPending: false });
|
||||
res.json({ needsSetup: true, mfaPending: false, localLoginEnabled: true, authenticationMode: 'local_and_sso' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,6 +109,7 @@ authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response):
|
||||
// Login endpoint
|
||||
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
const remember = req.body.remember === true;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
@@ -113,6 +117,13 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response):
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isLocalLoginEnabled()) {
|
||||
res.status(403).json({
|
||||
error: 'Local password authentication is disabled. Sign in using SSO.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
@@ -131,13 +142,13 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response):
|
||||
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
|
||||
}
|
||||
if (mfa?.enabled) {
|
||||
issueMfaPendingCookie(res, req, user, jwtSecret);
|
||||
issueMfaPendingCookie(res, req, user, jwtSecret, { remember });
|
||||
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
|
||||
res.json({ success: true, mfaRequired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, jwtSecret);
|
||||
issueSessionCookie(res, req, user, jwtSecret, remember);
|
||||
console.log('[Auth] Login successful:', user.username);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { SSOService } from '../services/SSOService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import {
|
||||
getAuthenticationMode,
|
||||
isAuthenticationMode,
|
||||
setAuthenticationMode,
|
||||
type AuthenticationMode,
|
||||
} from '../helpers/authenticationMode';
|
||||
|
||||
const SCOPE_MESSAGE = 'API tokens cannot change authentication mode.';
|
||||
|
||||
export const authModeRouter = Router();
|
||||
|
||||
authModeRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const mode = getAuthenticationMode();
|
||||
res.json({
|
||||
authenticationMode: mode,
|
||||
localLoginEnabled: mode !== 'sso_only',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AuthMode] Failed to read authentication mode:', error);
|
||||
res.status(500).json({ error: 'Failed to read authentication mode' });
|
||||
}
|
||||
});
|
||||
|
||||
authModeRouter.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const mode = req.body?.mode as unknown;
|
||||
if (!isAuthenticationMode(mode)) {
|
||||
res.status(400).json({ error: 'mode must be local_and_sso or sso_only' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'local_and_sso') {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
console.log('[AuthMode] Authentication mode set to local_and_sso');
|
||||
res.json({
|
||||
success: true,
|
||||
authenticationMode: 'local_and_sso' satisfies AuthenticationMode,
|
||||
localLoginEnabled: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Entering sso_only: safety gates.
|
||||
if (req.body?.confirm !== true) {
|
||||
res.status(400).json({ error: 'confirm must be true to enable SSO-only mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const admin = db.getUser(req.user!.userId);
|
||||
if (!admin || admin.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Administrator access required' });
|
||||
return;
|
||||
}
|
||||
if (admin.auth_provider === 'local') {
|
||||
res.status(400).json({
|
||||
error: 'Sign in with SSO as an administrator before enabling SSO-only mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
res.status(400).json({ error: 'Enable at least one SSO provider before SSO-only mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sso = SSOService.getInstance();
|
||||
let anyTestPassed = false;
|
||||
const failures: string[] = [];
|
||||
for (const config of enabled) {
|
||||
const result =
|
||||
config.provider === 'ldap'
|
||||
? await sso.testLdapConnection()
|
||||
: await sso.testOidcDiscovery(config.provider);
|
||||
if (result.success) {
|
||||
anyTestPassed = true;
|
||||
break;
|
||||
}
|
||||
failures.push(`${config.provider}: ${result.error ?? 'connection test failed'}`);
|
||||
}
|
||||
if (!anyTestPassed) {
|
||||
res.status(400).json({
|
||||
error: 'At least one enabled SSO provider must pass a connection test',
|
||||
details: failures,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticationMode('sso_only');
|
||||
console.log('[AuthMode] Authentication mode set to sso_only');
|
||||
res.json({
|
||||
success: true,
|
||||
authenticationMode: 'sso_only' satisfies AuthenticationMode,
|
||||
localLoginEnabled: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AuthMode] Failed to update authentication mode:', error);
|
||||
res.status(500).json({ error: 'Failed to update authentication mode' });
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import TrivyService from '../services/TrivyService';
|
||||
import { effectiveTier } from '../middleware/tierGates';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -49,11 +50,11 @@ export interface ConfigurationStatus {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLocalConfigurationStatus(
|
||||
export async function buildLocalConfigurationStatus(
|
||||
nodeId: number,
|
||||
userId: number,
|
||||
tier: LicenseTier,
|
||||
): ConfigurationStatus {
|
||||
): Promise<ConfigurationStatus> {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const agents = db.getAgents(nodeId);
|
||||
@@ -62,7 +63,10 @@ export function buildLocalConfigurationStatus(
|
||||
return { configured: !!a?.url, enabled: a?.enabled ?? false };
|
||||
};
|
||||
|
||||
const alertRules = db.getStackAlerts().length;
|
||||
// Scope to stacks that exist on this node. stack_alerts has no node_id;
|
||||
// intersecting with the node's compose directory is the per-node filter.
|
||||
const stackNames = new Set(await FileSystemService.getInstance(nodeId).getStacks());
|
||||
const alertRules = db.getStackAlerts().filter((a) => stackNames.has(a.stack_name)).length;
|
||||
const notifRoutes = db.getNotificationRoutes();
|
||||
|
||||
const healPolicies = db.getAutoHealPolicies(undefined, nodeId);
|
||||
@@ -165,7 +169,7 @@ export function buildLocalConfigurationStatus(
|
||||
}
|
||||
|
||||
// All routes below are protected by the global authGate mounted at app.use('/api', authGate)
|
||||
dashboardRouter.get('/configuration', (req: Request, res: Response): void => {
|
||||
dashboardRouter.get('/configuration', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const debug = isDebugEnabled();
|
||||
const startedAt = debug ? Date.now() : 0;
|
||||
@@ -173,7 +177,7 @@ dashboardRouter.get('/configuration', (req: Request, res: Response): void => {
|
||||
const userId = req.user?.userId ?? 0;
|
||||
const tier = effectiveTier(req);
|
||||
|
||||
const payload = buildLocalConfigurationStatus(nodeId, userId, tier);
|
||||
const payload = await buildLocalConfigurationStatus(nodeId, userId, tier);
|
||||
if (debug) {
|
||||
console.debug(
|
||||
`[Dashboard:debug] /configuration built in ${Date.now() - startedAt} ms (nodeId=${nodeId})`,
|
||||
|
||||
+201
-26
@@ -62,6 +62,8 @@ import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license-
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
|
||||
const updateTracker = FleetUpdateTrackerService.getInstance();
|
||||
/** Sync lock for remote reapply while meta is fetched (before the pollable tracker exists). */
|
||||
const remoteReapplyDispatching = new Set<number>();
|
||||
const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure
|
||||
// Shown in the Node Updates UI when a node's image is pinned in a way Fleet
|
||||
// cannot repin (digest or an unresolved value). Node-neutral so it reads the
|
||||
@@ -659,7 +661,7 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
|
||||
name: node.name,
|
||||
type: 'local',
|
||||
status: 'online',
|
||||
configuration: buildLocalConfigurationStatus(node.id, userId, localTier),
|
||||
configuration: await buildLocalConfigurationStatus(node.id, userId, localTier),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1114,6 +1116,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
let remoteImagePinKind: ImagePinKind | null = null;
|
||||
let remoteUpdateBlocked = false;
|
||||
let remoteImageChannel: 'community' | 'hardened' | 'unknown' | null = null;
|
||||
let remoteCapabilities: string[] = [];
|
||||
if (node.type === 'local') {
|
||||
version = gatewayVersion;
|
||||
} else {
|
||||
@@ -1125,8 +1128,18 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
remoteImagePinKind = meta.imagePinKind;
|
||||
remoteUpdateBlocked = meta.updateBlocked;
|
||||
remoteImageChannel = meta.imageChannel;
|
||||
remoteCapabilities = meta.capabilities ?? [];
|
||||
}
|
||||
|
||||
const isReapply = tracker?.operationKind === 'reapply_configuration';
|
||||
const earlyFailMsg = isReapply
|
||||
? (node.type === 'local'
|
||||
? 'Local reapply did not complete. The container may not have restarted; check Docker logs on the host.'
|
||||
: 'Reapply may have failed. The node is still running and its process start time has not changed.')
|
||||
: (node.type === 'local'
|
||||
? 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'
|
||||
: 'Update may have failed. The node is still running and its version has not changed.');
|
||||
|
||||
if (tracker?.status === 'updating') {
|
||||
const elapsed = Date.now() - tracker.startedAt;
|
||||
|
||||
@@ -1136,7 +1149,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
|
||||
if (elapsed > UPDATE_TIMEOUT_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's');
|
||||
resolveTerminal(node, tracker, 'timeout', UPDATE_TIMEOUT_MSG);
|
||||
resolveTerminal(node, tracker, 'timeout', isReapply
|
||||
? 'Node did not come back online within 5 minutes after reapply.'
|
||||
: UPDATE_TIMEOUT_MSG);
|
||||
} else if (node.type === 'remote') {
|
||||
if (remoteUpdateError) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError);
|
||||
@@ -1146,10 +1161,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)');
|
||||
updateTracker.set(node.id, { ...tracker, wasOffline: true });
|
||||
}
|
||||
} else if (isValidVersion(version) && version !== tracker.previousVersion) {
|
||||
// Signal 1: a valid, different version. A null/unparseable version
|
||||
// from a transient /api/meta blip is NOT a version change, so it
|
||||
// must not complete a still-running, same-process node here.
|
||||
} else if (!isReapply && isValidVersion(version) && version !== tracker.previousVersion) {
|
||||
// Signal 1: a valid, different version. Skipped for reapply because
|
||||
// the authored image/version is not expected to change.
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (
|
||||
@@ -1173,20 +1187,20 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online, startedAt unavailable)');
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (
|
||||
!isReapply &&
|
||||
elapsed > 15_000 &&
|
||||
isValidVersion(version) &&
|
||||
gatewayValid &&
|
||||
!semver.lt(version, compareVersion!)
|
||||
) {
|
||||
// Signal 4: remote is now at or above gateway version (after
|
||||
// minimum processing time). Catches fast restarts where the 5s
|
||||
// polling interval misses the offline window and startedAt
|
||||
// hasn't been observed to change yet.
|
||||
// minimum processing time). Never used for reapply: an already
|
||||
// current node would false-complete before the helper runs.
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 4 (version >= compare target):', version, '>=', compareVersion);
|
||||
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
|
||||
} else if (elapsed > EARLY_FAIL_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected');
|
||||
resolveTerminal(node, tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.');
|
||||
resolveTerminal(node, tracker, 'failed', earlyFailMsg);
|
||||
}
|
||||
} else if (node.type === 'local') {
|
||||
// Local node has only two failure signals: an explicit pull/spawn
|
||||
@@ -1202,7 +1216,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
selfUpdate.clearLastError();
|
||||
} else if (elapsed > EARLY_FAIL_MS) {
|
||||
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's');
|
||||
resolveTerminal(node, tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.');
|
||||
resolveTerminal(node, tracker, 'failed', earlyFailMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1286,6 +1300,10 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
updateBlocked,
|
||||
updateBlockedReason,
|
||||
imageChannel,
|
||||
operationKind: currentTracker?.operationKind ?? null,
|
||||
canReapplyCompose: node.type === 'local'
|
||||
? SelfUpdateService.getInstance().isAvailable()
|
||||
: remoteOnline && remoteCapabilities.includes('self-update'),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1305,6 +1323,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
|
||||
skipActive: false,
|
||||
skippedVersion: null,
|
||||
...EMPTY_PIN_STATUS,
|
||||
operationKind: null,
|
||||
canReapplyCompose: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1345,17 +1365,46 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ
|
||||
// repin a semver-pinned compose to that release. It is omitted otherwise (never
|
||||
// sent as null/invalid), and an older remote that predates this field simply
|
||||
// ignores the extra body key and behaves as before.
|
||||
function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) {
|
||||
function postSystemEndpoint(
|
||||
target: { apiUrl: string; apiToken: string },
|
||||
endpoint: '/api/system/update' | '/api/system/reapply-compose',
|
||||
body: Record<string, unknown> = {},
|
||||
) {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
|
||||
return fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/update`, {
|
||||
return fetch(`${target.apiUrl.replace(/\/$/, '')}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(targetVersion ? { targetVersion } : {}),
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
}
|
||||
|
||||
function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) {
|
||||
return postSystemEndpoint(target, '/api/system/update', targetVersion ? { targetVersion } : {});
|
||||
}
|
||||
|
||||
function postSystemReapplyCompose(target: { apiUrl: string; apiToken: string }) {
|
||||
return postSystemEndpoint(target, '/api/system/reapply-compose');
|
||||
}
|
||||
|
||||
/** Clear a terminal tracker row, or time out a stale in-flight one. Returns a
|
||||
* conflict message when another update/reapply is still actively running. */
|
||||
function beginTrackerOperation(nodeId: number, conflictError: string): string | null {
|
||||
const existing = updateTracker.get(nodeId);
|
||||
if (existing?.status === 'updating') {
|
||||
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
|
||||
updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||
} else {
|
||||
return conflictError;
|
||||
}
|
||||
}
|
||||
if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) {
|
||||
updateTracker.delete(nodeId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRemoteUpdateFailure(payload: unknown): { error: string; code?: string } {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { error: 'Remote node rejected update request.' };
|
||||
@@ -1435,18 +1484,10 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
|
||||
const requestedTarget = parseRequestedTargetVersion(req, res);
|
||||
if (requestedTarget === null) return; // invalid supplied value; 400 already sent
|
||||
|
||||
const existing = updateTracker.get(nodeId);
|
||||
if (existing?.status === 'updating') {
|
||||
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
|
||||
updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||
} else {
|
||||
res.status(409).json({ error: 'Update already in progress for this node.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Clear terminal states to allow retry.
|
||||
if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) {
|
||||
updateTracker.delete(nodeId);
|
||||
const conflict = beginTrackerOperation(nodeId, 'Update already in progress for this node.');
|
||||
if (conflict) {
|
||||
res.status(409).json({ error: conflict });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Fleet] Update triggered for node', node.name, node.type);
|
||||
@@ -1548,6 +1589,140 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
|
||||
}
|
||||
});
|
||||
|
||||
fleetRouter.post('/nodes/:nodeId/reapply-compose', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
if (nodeId === null) return;
|
||||
const db = DatabaseService.getInstance();
|
||||
const node = db.getNode(nodeId);
|
||||
if (!node) {
|
||||
res.status(404).json({ error: 'Node not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const conflict = beginTrackerOperation(nodeId, 'An update or reapply is already in progress for this node.');
|
||||
if (conflict) {
|
||||
res.status(409).json({ error: conflict });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Fleet] Compose reapply triggered for node', node.name, node.type);
|
||||
|
||||
if (node.type === 'local') {
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
if (!selfUpdate.isAvailable()) {
|
||||
res.status(503).json({ error: 'Compose reapply unavailable on the local node.' });
|
||||
return;
|
||||
}
|
||||
const claim = await ImageOperationService.getInstance().claimComposeReapply();
|
||||
if (!claim.ok) {
|
||||
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
|
||||
return;
|
||||
}
|
||||
updateTracker.set(
|
||||
nodeId,
|
||||
updateTracker.create('updating', getSenchoVersion(), null, undefined, undefined, 'reapply_configuration'),
|
||||
);
|
||||
res.status(202).json({ message: 'Compose reapply initiated on local node. The server will restart shortly.' });
|
||||
setTimeout(() => {
|
||||
ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => {
|
||||
console.error('[ImageOperation] Unexpected compose reapply failure:', error);
|
||||
});
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync lock before any await so a concurrent reapply gets 409 without
|
||||
// racing the remote POST. The pollable tracker is created only after meta
|
||||
// is known (full process identity), immediately before dispatch.
|
||||
if (remoteReapplyDispatching.has(nodeId)) {
|
||||
res.status(409).json({ error: 'An update or reapply is already in progress for this node.' });
|
||||
return;
|
||||
}
|
||||
remoteReapplyDispatching.add(nodeId);
|
||||
|
||||
const failOwnedTracker = (
|
||||
error: string,
|
||||
code?: string,
|
||||
previousVersion: string | null = null,
|
||||
previousProcessStart: number | null = null,
|
||||
) => {
|
||||
const current = updateTracker.get(nodeId);
|
||||
if (current?.status !== 'updating' || current.operationKind !== 'reapply_configuration') return;
|
||||
updateTracker.set(
|
||||
nodeId,
|
||||
updateTracker.create('failed', previousVersion, previousProcessStart, error, code, 'reapply_configuration'),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target) {
|
||||
const error = formatNoTargetError(node);
|
||||
res.status(503).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id);
|
||||
if (!meta.online) {
|
||||
const error = 'Remote node is unreachable. Verify the node is running and the API URL is correct.';
|
||||
res.status(503).json({ error });
|
||||
return;
|
||||
}
|
||||
if (!meta.capabilities.includes('self-update')) {
|
||||
const error = 'Remote node does not support compose reapply. It may need to be updated manually first.';
|
||||
res.status(503).json({ error });
|
||||
return;
|
||||
}
|
||||
// Digest pins and updateBlocked are intentional non-gates: reapply never
|
||||
// repins the image, so blocked update rows remain eligible.
|
||||
|
||||
// Reserve before the remote POST so a concurrent reapply still sees
|
||||
// 'updating' after this request leaves the dispatch set in finally.
|
||||
updateTracker.set(
|
||||
nodeId,
|
||||
updateTracker.create(
|
||||
'updating',
|
||||
meta.version,
|
||||
meta.startedAt,
|
||||
undefined,
|
||||
undefined,
|
||||
'reapply_configuration',
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postSystemReapplyCompose(target);
|
||||
|
||||
if (!response.ok) {
|
||||
const failure = parseRemoteUpdateFailure(await response.json().catch(() => null));
|
||||
failOwnedTracker(failure.error, failure.code, meta.version, meta.startedAt);
|
||||
res.status(502).json(failure);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(202).json({ message: `Compose reapply initiated on ${node.name}.` });
|
||||
} finally {
|
||||
remoteReapplyDispatching.delete(nodeId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Fleet] Node compose reapply error:', error);
|
||||
const errorMsg = getErrorMessage(error, 'Failed to trigger compose reapply.');
|
||||
const failedNodeId = parseInt(req.params.nodeId as string, 10);
|
||||
if (!isNaN(failedNodeId)) {
|
||||
remoteReapplyDispatching.delete(failedNodeId);
|
||||
const current = updateTracker.get(failedNodeId);
|
||||
if (current?.status === 'updating' && current.operationKind === 'reapply_configuration') {
|
||||
updateTracker.set(
|
||||
failedNodeId,
|
||||
updateTracker.create('failed', null, null, errorMsg, undefined, 'reapply_configuration'),
|
||||
);
|
||||
}
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to trigger compose reapply.' });
|
||||
}
|
||||
});
|
||||
|
||||
fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,13 @@ import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
import { ImageUpdateService } from '../services/ImageUpdateService';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenDigestApplyBlockedByCheckErrors,
|
||||
messageWhenNoDigestUpdate,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
import { ImageUpdateService, UPDATE_VERIFICATION_INCOMPLETE_WARNING } from '../services/ImageUpdateService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { StackUpdateOrchestrator } from '../services/StackUpdateOrchestrator';
|
||||
import { StackOpLockService, stackOpSkipMessage } from '../services/StackOpLockService';
|
||||
@@ -15,6 +21,8 @@ import { HealthGateService } from '../services/HealthGateService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { buildPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { FLEET_UPDATE_CACHE_KEY, invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { summarizeBlockReasons } from '../utils/policy-risk';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -22,7 +30,6 @@ import { logDebugTiming } from '../utils/requestTiming';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
// Fleet aggregation cache: 2-minute TTL, shared across dashboard tabs.
|
||||
const FLEET_UPDATE_CACHE_KEY = 'fleet-updates';
|
||||
const FLEET_CACHE_TTL = 120_000;
|
||||
const REMOTE_NODE_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
@@ -30,7 +37,9 @@ export const imageUpdatesRouter = Router();
|
||||
|
||||
imageUpdatesRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
|
||||
// Confirmed-only: partial/failed retained has_update rows stay out of the
|
||||
// boolean map so Fleet and node cards do not treat uncertainty as pending.
|
||||
const updates = DatabaseService.getInstance().getConfirmedStackUpdateStatus(req.nodeId);
|
||||
res.json(updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch image update status:', error);
|
||||
@@ -68,6 +77,13 @@ imageUpdatesRouter.get('/detail', authMiddleware, (req: Request, res: Response):
|
||||
imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
res.status(409).json({
|
||||
enabled: false,
|
||||
error: 'Image update detection is disabled for this node.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
|
||||
if (!triggered) {
|
||||
const mins = ImageUpdateService.manualCooldownMinutes;
|
||||
@@ -172,6 +188,26 @@ imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response
|
||||
}
|
||||
});
|
||||
|
||||
const EnabledPatchSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const parsed = EnabledPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = ImageUpdateService.getInstance().applyChecksEnabled(parsed.data.enabled);
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update image-update checks enabled:', error);
|
||||
res.status(500).json({ error: 'Failed to update image update checks setting' });
|
||||
}
|
||||
});
|
||||
|
||||
imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
@@ -184,10 +220,10 @@ imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Respo
|
||||
const nr = NodeRegistry.getInstance();
|
||||
const data: Record<number, Record<string, boolean>> = {};
|
||||
|
||||
// Local nodes: synchronous DB reads.
|
||||
// Local nodes: synchronous DB reads (confirmed-only projection).
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'local') {
|
||||
data[node.id] = db.getStackUpdateStatus(node.id);
|
||||
data[node.id] = db.getConfirmedStackUpdateStatus(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +281,7 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
|
||||
const triggered: number[] = [];
|
||||
const rateLimited: number[] = [];
|
||||
const failed: number[] = [];
|
||||
const disabled: number[] = [];
|
||||
|
||||
// ImageUpdateService is a per-instance singleton, so the local node's manual
|
||||
// refresh fires at most once per request regardless of how many local rows
|
||||
@@ -252,7 +289,9 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
|
||||
const localNode = nodes.find(n => n.type === 'local');
|
||||
if (localNode) {
|
||||
try {
|
||||
if (ImageUpdateService.getInstance().triggerManualRefresh()) {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
disabled.push(localNode.id);
|
||||
} else if (ImageUpdateService.getInstance().triggerManualRefresh()) {
|
||||
triggered.push(localNode.id);
|
||||
} else {
|
||||
rateLimited.push(localNode.id);
|
||||
@@ -297,6 +336,8 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
|
||||
const { nodeId, status } = entry.value;
|
||||
if (status >= 200 && status < 300) {
|
||||
triggered.push(nodeId);
|
||||
} else if (status === 409) {
|
||||
disabled.push(nodeId);
|
||||
} else if (status === 429) {
|
||||
rateLimited.push(nodeId);
|
||||
} else {
|
||||
@@ -304,8 +345,8 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
|
||||
}
|
||||
}
|
||||
|
||||
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
|
||||
res.json({ triggered, rateLimited, failed });
|
||||
invalidateFleetUpdateCache();
|
||||
res.json({ triggered, rateLimited, failed, disabled });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -318,31 +359,58 @@ export const autoUpdateRouter = Router();
|
||||
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target } = req.body as { target?: string };
|
||||
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
|
||||
if (!target || typeof target !== 'string') {
|
||||
res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' });
|
||||
// Honor the node-scoped image-update detection opt-out before any work.
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
res.json({ result: 'Image update detection is disabled for this node; skipped.' });
|
||||
return;
|
||||
}
|
||||
const { target, targets } = req.body as { target?: string; targets?: unknown };
|
||||
|
||||
let stackNames: string[];
|
||||
if (target === '*') {
|
||||
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stackNames.length === 0) {
|
||||
res.json({ result: 'No stacks found on node; skipped.' });
|
||||
if (Array.isArray(targets)) {
|
||||
if (targets.length === 0) {
|
||||
res.status(400).json({ error: '"targets" must be a non-empty array of stack names' });
|
||||
return;
|
||||
}
|
||||
if (targets.length > 500) {
|
||||
res.status(400).json({ error: '"targets" accepts at most 500 stack names' });
|
||||
return;
|
||||
}
|
||||
if (!targets.every((t): t is string => typeof t === 'string' && isValidStackName(t))) {
|
||||
res.status(400).json({ error: 'Invalid stack name in targets' });
|
||||
return;
|
||||
}
|
||||
// Deduplicate while preserving order.
|
||||
const seen = new Set<string>();
|
||||
stackNames = [];
|
||||
for (const name of targets) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
stackNames.push(name);
|
||||
}
|
||||
console.log(`[AutoUpdate] Execute requested: targets=${stackNames.length}`);
|
||||
} else if (typeof target === 'string' && target.length > 0) {
|
||||
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target)}"`);
|
||||
if (target === '*') {
|
||||
stackNames = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
if (stackNames.length === 0) {
|
||||
res.json({ result: 'No stacks found on node; skipped.' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!isValidStackName(target)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
stackNames = [target];
|
||||
}
|
||||
} else {
|
||||
if (!isValidStackName(target)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
stackNames = [target];
|
||||
res.status(400).json({ error: 'Missing "target" (stack name or "*") or "targets" (stack name array)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const docker = DockerController.getInstance(req.nodeId);
|
||||
const imageUpdateService = ImageUpdateService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const atomic = true;
|
||||
const results: string[] = [];
|
||||
|
||||
@@ -365,35 +433,29 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
continue;
|
||||
}
|
||||
|
||||
let hasUpdate = false;
|
||||
const updatedImages: string[] = [];
|
||||
const checkErrors: string[] = [];
|
||||
const gate = createAutoUpdateDigestGateState();
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const result = await imageUpdateService.checkImage(docker, imageRef);
|
||||
if (result.error) {
|
||||
checkErrors.push(result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
hasUpdate = true;
|
||||
updatedImages.push(imageRef);
|
||||
}
|
||||
recordAutoUpdateImageCheck(gate, imageRef, result);
|
||||
} catch (e) {
|
||||
const errMsg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(errMsg);
|
||||
gate.checkErrors.push(errMsg);
|
||||
console.warn('[AutoUpdate] Failed to check image %s:', sanitizeForLog(imageRef), sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdate) {
|
||||
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
|
||||
results.push(`Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`);
|
||||
} else if (checkErrors.length > 0) {
|
||||
results.push(`Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`);
|
||||
} else {
|
||||
results.push(`Stack "${stackName}": all images up to date.`);
|
||||
}
|
||||
if (!gate.hasDigestUpdate) {
|
||||
results.push(messageWhenNoDigestUpdate(stackName, gate, imageRefs.length));
|
||||
continue;
|
||||
}
|
||||
const checkErrorBlock = messageWhenDigestApplyBlockedByCheckErrors(stackName, gate);
|
||||
if (checkErrorBlock) {
|
||||
results.push(checkErrorBlock);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { updatedImages } = gate;
|
||||
|
||||
// Auto-update runs from the scheduler: a policy bypass is never
|
||||
// appropriate. If updated images fail the gate, skip the stack and
|
||||
@@ -425,7 +487,9 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
results.push(stackOpSkipMessage(stackName, lock.existing.action));
|
||||
continue;
|
||||
}
|
||||
db.clearStackUpdateStatus(req.nodeId, stackName);
|
||||
|
||||
// Health observation starts immediately after Compose; registry recheck is
|
||||
// isolated so a verification failure cannot turn Compose success into a failure.
|
||||
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', `auto-update:${req.user?.username ?? 'scheduler'}`);
|
||||
const orchResult = lock.result;
|
||||
const recoveryId = orchResult && orchResult.kind === 'stack_compose_done' ? orchResult.recoveryId : null;
|
||||
@@ -434,6 +498,21 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
StackUpdateRecoveryService.getInstance().linkGateOrRetain(recoveryId, healthGateId);
|
||||
}
|
||||
|
||||
// Recheck persists digest-cleared / tag-advisory state. Do not blind-clear.
|
||||
let recheckWarning: string | undefined;
|
||||
try {
|
||||
const recheck = await imageUpdateService.recheckStack(req.nodeId, stackName);
|
||||
if (recheck.warning) recheckWarning = recheck.warning;
|
||||
} catch (recheckErr) {
|
||||
console.warn(
|
||||
'[AutoUpdate] Post-update recheck failed for %s: %s',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(recheckErr, 'unknown')),
|
||||
);
|
||||
recheckWarning = UPDATE_VERIFICATION_INCOMPLETE_WARNING;
|
||||
}
|
||||
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
@@ -450,15 +529,20 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
|
||||
{ stackName, actor: 'system:image-update' },
|
||||
);
|
||||
|
||||
results.push(`Stack "${stackName}": updated (${updatedImages.join(', ')}).`);
|
||||
const base = `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
|
||||
results.push(recheckWarning ? `${base} ${recheckWarning}` : base);
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
results.push(`Stack "${stackName}" failed: ${msg}`);
|
||||
console.error(`[AutoUpdate] Failed for stack "${stackName}":`, e);
|
||||
console.error(
|
||||
'[AutoUpdate] Failed for stack %s: %s',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(msg),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CacheService.getInstance().invalidate(FLEET_UPDATE_CACHE_KEY);
|
||||
invalidateFleetUpdateCache();
|
||||
res.json({ result: results.join('\n') });
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error, 'Auto-update execution failed');
|
||||
|
||||
@@ -175,3 +175,23 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise<
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
|
||||
systemUpdateRouter.post('/reapply-compose', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
if (!selfUpdate.isAvailable()) {
|
||||
res.status(503).json({ error: 'Compose reapply unavailable. Sencho must be deployed via Docker Compose.' });
|
||||
return;
|
||||
}
|
||||
const claim = await ImageOperationService.getInstance().claimComposeReapply();
|
||||
if (!claim.ok) {
|
||||
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
|
||||
return;
|
||||
}
|
||||
res.status(202).json({ message: 'Compose reapply initiated. The server will restart shortly.' });
|
||||
setTimeout(() => {
|
||||
ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => {
|
||||
console.error('[ImageOperation] Unexpected compose reapply failure:', error);
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
|
||||
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean; remember?: boolean };
|
||||
try {
|
||||
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
|
||||
} catch {
|
||||
@@ -173,7 +173,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response
|
||||
|
||||
db.clearMfaFailures(decoded.user_id);
|
||||
clearMfaPendingCookie(res, req);
|
||||
issueSessionCookie(res, req, user, jwtSecret);
|
||||
issueSessionCookie(res, req, user, jwtSecret, decoded.remember === true);
|
||||
console.log('[Auth] MFA challenge cleared:', user.username);
|
||||
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService';
|
||||
import {
|
||||
DatabaseService,
|
||||
type NotificationSuppressionAppliesTo,
|
||||
type NotificationSuppressionRetraction,
|
||||
type NotificationSuppressionRule,
|
||||
} from '../services/DatabaseService';
|
||||
import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService';
|
||||
import type { NotificationCategory } from '../services/NotificationService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
@@ -37,6 +42,42 @@ const VALID_SUPPRESSION_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(
|
||||
const VALID_LEVELS = new Set(['info', 'warning', 'error']);
|
||||
const VALID_APPLIES_TO = new Set<NotificationSuppressionAppliesTo>(['bell', 'external', 'both']);
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted / empty body (old hub) -> permanent watermark 0.
|
||||
* Present body with any keys must be a complete valid retraction or 400.
|
||||
*/
|
||||
function parseReplicaRetractionBody(
|
||||
body: unknown,
|
||||
res: Response,
|
||||
): NotificationSuppressionRetraction | false {
|
||||
if (
|
||||
body == null ||
|
||||
(typeof body === 'object' && !Array.isArray(body) && Object.keys(body as object).length === 0)
|
||||
) {
|
||||
return { kind: 'permanent', source_updated_at: 0 };
|
||||
}
|
||||
if (typeof body !== 'object' || Array.isArray(body)) {
|
||||
res.status(400).json({ error: 'retraction body must be an object' });
|
||||
return false;
|
||||
}
|
||||
const raw = body as Record<string, unknown>;
|
||||
const kind = raw.kind;
|
||||
const source = raw.source_updated_at;
|
||||
if (kind !== 'permanent' && kind !== 'recoverable') {
|
||||
res.status(400).json({ error: 'kind must be permanent or recoverable' });
|
||||
return false;
|
||||
}
|
||||
if (!isNonNegativeSafeInteger(source)) {
|
||||
res.status(400).json({ error: 'source_updated_at must be a non-negative safe integer' });
|
||||
return false;
|
||||
}
|
||||
return { kind, source_updated_at: source };
|
||||
}
|
||||
|
||||
function validateNodeId(nodeId: unknown, res: Response): number | null | false {
|
||||
if (nodeId === undefined || nodeId === null) return null;
|
||||
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
|
||||
@@ -550,8 +591,16 @@ notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, re
|
||||
isCreate: true,
|
||||
}, res);
|
||||
if (scheduleResult === false) return;
|
||||
if (!isNonNegativeSafeInteger(rule.created_at)) {
|
||||
res.status(400).json({ error: 'created_at must be a non-negative safe integer' });
|
||||
return;
|
||||
}
|
||||
if (!isNonNegativeSafeInteger(rule.updated_at)) {
|
||||
res.status(400).json({ error: 'updated_at must be a non-negative safe integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||
const outcome = DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||
...rule,
|
||||
stack_patterns: patterns.patterns,
|
||||
label_ids: Array.isArray(rule.label_ids) && rule.label_ids.length > 0 ? rule.label_ids : null,
|
||||
@@ -561,11 +610,13 @@ notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, re
|
||||
scheduleInvalid: false,
|
||||
enabled: rule.enabled !== false,
|
||||
expires_at: rule.expires_at ?? null,
|
||||
created_at: rule.created_at,
|
||||
updated_at: rule.updated_at,
|
||||
// Replicas are always node-agnostic on the receiving node: the hub's node_id
|
||||
// is a hub-local scoping concept and never trustworthy as a foreign key here.
|
||||
node_id: null,
|
||||
});
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, outcome });
|
||||
} catch (error) {
|
||||
console.error('Failed to apply suppression rule replica:', error);
|
||||
res.status(500).json({ error: 'Failed to apply suppression rule replica' });
|
||||
@@ -577,8 +628,10 @@ notificationSuppressionRouter.delete('/replica/:id', authMiddleware, (req: Reque
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
|
||||
if (id === null) return;
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
res.json({ success: true });
|
||||
const retraction = parseReplicaRetractionBody(req.body, res);
|
||||
if (retraction === false) return;
|
||||
const { outcome } = DatabaseService.getInstance().deleteNotificationSuppressionRule(id, retraction);
|
||||
res.json({ success: true, outcome });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete suppression rule replica:', error);
|
||||
res.status(500).json({ error: 'Failed to delete suppression rule replica' });
|
||||
@@ -727,7 +780,10 @@ notificationSuppressionRouter.delete('/:id', authMiddleware, (req: Request, res:
|
||||
const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; }
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id, {
|
||||
kind: 'permanent',
|
||||
source_updated_at: existing.updated_at,
|
||||
});
|
||||
deleteSuppressionRuleFromFleet(existing);
|
||||
console.log(`[Suppression] Rule ${id} deleted`);
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -35,6 +35,17 @@ function broadcastScheduledTasksChanged(): void {
|
||||
|
||||
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
||||
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
||||
const STACK_LABEL_SELECTOR = 'stack-label';
|
||||
const LABEL_NAME_RE = /^[a-zA-Z0-9 -]+$/;
|
||||
|
||||
function isStackLabelSelector(selectorType: unknown): boolean {
|
||||
return selectorType === STACK_LABEL_SELECTOR;
|
||||
}
|
||||
|
||||
/** True when this update+fleet task uses a stack-label selector (node_id may be null). */
|
||||
function usesStackLabelSelector(action: BackendScheduledAction, targetType: TargetType, selectorType: unknown): boolean {
|
||||
return action === 'update' && targetType === 'fleet' && isStackLabelSelector(selectorType);
|
||||
}
|
||||
|
||||
function parsePositiveNodeId(nodeId: unknown): number | null {
|
||||
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
|
||||
@@ -98,17 +109,26 @@ function validateContainerTarget(targetType: TargetType, targetId: unknown, node
|
||||
|
||||
/**
|
||||
* Shared guard for non-stack actions that require a node. Stack actions use
|
||||
* validateStackTarget because they also require target_id.
|
||||
* validateStackTarget because they also require target_id. Label-targeted
|
||||
* fleet updates may omit node_id (entire fleet); pass selectorType so that
|
||||
* path is allowed.
|
||||
*/
|
||||
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
|
||||
function validateActionNode(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
nodeId: unknown,
|
||||
selectorType?: unknown,
|
||||
): string | null {
|
||||
if (targetType === 'stack' || targetType === 'container') return null;
|
||||
const def = getScheduledActionDefinition(action);
|
||||
if (!def?.requiresNode) return null;
|
||||
|
||||
const labelSingular = nodeRequirementLabel(action, targetType);
|
||||
const labelPlural = localNodeRequirementLabel(action);
|
||||
const labelFleetUpdate = usesStackLabelSelector(action, targetType, selectorType);
|
||||
|
||||
if (nodeId == null) {
|
||||
if (labelFleetUpdate) return null;
|
||||
return action === 'update' && targetType === 'fleet'
|
||||
? ERR_FLEET_NODE_REQUIRED
|
||||
: `${labelSingular} action requires node_id.`;
|
||||
@@ -124,13 +144,15 @@ function validateActionNode(action: BackendScheduledAction, targetType: TargetTy
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
|
||||
/** Shared validation for prune_targets, target_services, prune_label_filter, selector_*. Returns an error string or null. */
|
||||
function validateOptionalFields(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
prune_targets: unknown,
|
||||
target_services: unknown,
|
||||
prune_label_filter: unknown,
|
||||
selector_type?: unknown,
|
||||
selector_value?: unknown,
|
||||
): string | null {
|
||||
if (prune_targets !== undefined && prune_targets !== null) {
|
||||
if (!Array.isArray(prune_targets) || prune_targets.length === 0
|
||||
@@ -155,9 +177,39 @@ function validateOptionalFields(
|
||||
return 'prune_label_filter can only be used with prune action';
|
||||
}
|
||||
}
|
||||
|
||||
const selectorPresent = (selector_type !== undefined && selector_type !== null)
|
||||
|| (selector_value !== undefined && selector_value !== null);
|
||||
if (selectorPresent) {
|
||||
if (action !== 'update' || targetType !== 'fleet') {
|
||||
return 'selector fields can only be used with update action on fleet target';
|
||||
}
|
||||
if (selector_type !== STACK_LABEL_SELECTOR) {
|
||||
return 'selector_type must be "stack-label"';
|
||||
}
|
||||
if (typeof selector_value !== 'string' || selector_value.trim().length === 0 || selector_value.trim().length > 30) {
|
||||
return 'selector_value is required and must be 1-30 characters';
|
||||
}
|
||||
if (!LABEL_NAME_RE.test(selector_value.trim())) {
|
||||
return 'selector_value may only contain letters, numbers, spaces, and hyphens';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSelectorFields(
|
||||
action: BackendScheduledAction,
|
||||
targetType: TargetType,
|
||||
selector_type: unknown,
|
||||
selector_value: unknown,
|
||||
): { selector_type: string | null; selector_value: string | null } {
|
||||
if (action === 'update' && targetType === 'fleet' && isStackLabelSelector(selector_type)
|
||||
&& typeof selector_value === 'string' && selector_value.trim()) {
|
||||
return { selector_type: STACK_LABEL_SELECTOR, selector_value: selector_value.trim() };
|
||||
}
|
||||
return { selector_type: null, selector_value: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression for Scheduled Operations. The scheduler ticks once
|
||||
* per minute, so an expression with a leading seconds field (6 or more fields)
|
||||
@@ -238,7 +290,11 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
||||
const {
|
||||
name, target_type, target_id, node_id, action, cron_expression, enabled,
|
||||
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
delete_after_run, run_at,
|
||||
} = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' }); return;
|
||||
@@ -253,14 +309,16 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
const targetErr = validateActionTarget(action, target_type);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
const nodeErr = validateActionNode(action, target_type, node_id);
|
||||
const nodeErr = validateActionNode(action, target_type, node_id, selector_type);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
|
||||
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
||||
const containerTargetErr = validateContainerTarget(target_type, target_id, node_id);
|
||||
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
|
||||
|
||||
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
|
||||
const optionalErr = validateOptionalFields(
|
||||
action, target_type, prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
);
|
||||
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
||||
|
||||
const cronErr = validateCronExpression(cron_expression);
|
||||
@@ -282,7 +340,14 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
|
||||
const normalizedTargetId =
|
||||
target_type === 'stack' || target_type === 'container' ? target_id : null;
|
||||
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
|
||||
const labelSelector = usesStackLabelSelector(action, target_type, selector_type);
|
||||
const normalizedNodeId = labelSelector
|
||||
? (node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id))
|
||||
: (actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null);
|
||||
if (labelSelector && node_id != null && node_id !== '' && normalizedNodeId === null) {
|
||||
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
|
||||
}
|
||||
const selectors = normalizeSelectorFields(action, target_type, selector_type, selector_value);
|
||||
|
||||
const id = DatabaseService.getInstance().createScheduledTask({
|
||||
name: name.trim(),
|
||||
@@ -302,6 +367,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null,
|
||||
target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null,
|
||||
prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null,
|
||||
selector_type: selectors.selector_type,
|
||||
selector_value: selectors.selector_value,
|
||||
delete_after_run: delete_after_run ? 1 : 0,
|
||||
run_at: pinnedRunAt,
|
||||
});
|
||||
@@ -340,7 +407,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
||||
const {
|
||||
name, target_type, target_id, node_id, action, cron_expression, enabled,
|
||||
prune_targets, target_services, prune_label_filter, selector_type, selector_value,
|
||||
delete_after_run, run_at,
|
||||
} = req.body;
|
||||
|
||||
if (target_type !== undefined && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
|
||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
||||
@@ -354,13 +425,18 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container'
|
||||
? (target_id !== undefined ? target_id : existing.target_id)
|
||||
: null;
|
||||
const finalNodeId = actionRequiresNode(finalAction)
|
||||
const finalSelectorType = selector_type !== undefined ? selector_type : existing.selector_type;
|
||||
const finalSelectorValue = selector_value !== undefined ? selector_value : existing.selector_value;
|
||||
const labelSelector = usesStackLabelSelector(finalAction, finalTargetType, finalSelectorType);
|
||||
const finalNodeId = labelSelector
|
||||
? (node_id !== undefined ? node_id : existing.node_id)
|
||||
: null;
|
||||
: (actionRequiresNode(finalAction)
|
||||
? (node_id !== undefined ? node_id : existing.node_id)
|
||||
: null);
|
||||
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
|
||||
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
|
||||
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId, finalSelectorType);
|
||||
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
||||
|
||||
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||
@@ -369,7 +445,11 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId);
|
||||
if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; }
|
||||
|
||||
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
|
||||
const optionalErr = validateOptionalFields(
|
||||
finalAction, finalTargetType, prune_targets, target_services, prune_label_filter,
|
||||
selector_type !== undefined ? selector_type : finalSelectorType,
|
||||
selector_value !== undefined ? selector_value : finalSelectorValue,
|
||||
);
|
||||
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
||||
|
||||
if (cron_expression !== undefined) {
|
||||
@@ -391,7 +471,17 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) {
|
||||
updates.target_id = finalTargetId || null;
|
||||
}
|
||||
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
|
||||
// Label-targeted fleet updates keep node_id when provided (or existing);
|
||||
// clear only when the caller explicitly sends null/empty for fleet-wide, or
|
||||
// when the action no longer requires a node and is not a label selector.
|
||||
if (labelSelector) {
|
||||
if (node_id !== undefined) {
|
||||
updates.node_id = node_id == null || node_id === '' ? null : parsePositiveNodeId(node_id);
|
||||
if (node_id != null && node_id !== '' && updates.node_id === null) {
|
||||
res.status(400).json({ error: 'Fleet update action requires a valid node_id.' }); return;
|
||||
}
|
||||
}
|
||||
} else if (node_id !== undefined || !actionRequiresNode(finalAction)) {
|
||||
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
|
||||
}
|
||||
if (action !== undefined) updates.action = finalAction;
|
||||
@@ -414,6 +504,12 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
} else if (finalAction !== 'prune') {
|
||||
updates.prune_label_filter = null;
|
||||
}
|
||||
if (selector_type !== undefined || selector_value !== undefined
|
||||
|| finalAction !== 'update' || finalTargetType !== 'fleet') {
|
||||
const selectors = normalizeSelectorFields(finalAction, finalTargetType, finalSelectorType, finalSelectorValue);
|
||||
updates.selector_type = selectors.selector_type;
|
||||
updates.selector_value = selectors.selector_value;
|
||||
}
|
||||
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
||||
|
||||
// Persist a re-supplied run_at to its column (a number pins a one-shot; null
|
||||
|
||||
@@ -36,6 +36,7 @@ const ALLOWED_SETTING_KEYS = new Set([
|
||||
'auto_create_missing_external_networks',
|
||||
'image_update_sidebar_indicators',
|
||||
'notification_dispatch_retries',
|
||||
'session_sliding_refresh',
|
||||
]);
|
||||
|
||||
// Keys whose write requires a paid license, not just an admin role.
|
||||
@@ -77,6 +78,7 @@ const SettingsPatchSchema = z.object({
|
||||
});
|
||||
}
|
||||
}).transform((v) => String(parseNotificationDispatchRetries(v)!)),
|
||||
session_sliding_refresh: z.enum(['0', '1']),
|
||||
}).partial();
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@@ -43,6 +43,7 @@ ssoRouter.get('/providers', (_req: Request, res: Response): void => {
|
||||
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const remember = req.body.remember === true;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password are required' });
|
||||
return;
|
||||
@@ -71,13 +72,13 @@ ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Pr
|
||||
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
|
||||
}
|
||||
if (mfa?.enabled && mfa.sso_enforce_mfa) {
|
||||
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
|
||||
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true, remember });
|
||||
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
|
||||
res.json({ success: true, mfaRequired: true });
|
||||
return;
|
||||
}
|
||||
|
||||
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
|
||||
issueSessionCookie(res, req, user, settings.auth_jwt_secret, remember);
|
||||
console.log(`[SSO] LDAP login successful: ${user.username}`);
|
||||
res.json({ success: true, message: 'Login successful' });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SSOService, type SSOProviderConfig } from '../services/SSOService';
|
||||
import { requireAdmin, requireTierForSsoProvider } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { wouldRemoveLastProvider } from '../helpers/authenticationMode';
|
||||
|
||||
const VALID_SSO_PROVIDERS = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'] as const;
|
||||
const SSO_SCOPE_MESSAGE = 'API tokens cannot access SSO configuration.';
|
||||
@@ -85,6 +86,15 @@ ssoConfigRouter.put('/:provider', (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const existing = DatabaseService.getInstance().getSSOConfig(provider);
|
||||
const wasEnabled = existing?.enabled === 1;
|
||||
if (!config.enabled && wouldRemoveLastProvider(provider, wasEnabled)) {
|
||||
res.status(400).json({
|
||||
error: 'Cannot disable the last SSO provider while SSO-only mode is active. Switch to Local and SSO first, or use the emergency CLI.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
SSOService.getInstance().saveProviderConfig(config);
|
||||
console.log(`[SSO] Config updated: ${sanitizeForLog(provider)} ${config.enabled ? 'enabled' : 'disabled'}`);
|
||||
res.json({ success: true, message: 'SSO configuration saved' });
|
||||
@@ -101,6 +111,15 @@ ssoConfigRouter.delete('/:provider', (req: Request, res: Response): void => {
|
||||
if (rejectInvalidProvider(provider, res)) return;
|
||||
if (!requireTierForSsoProvider(provider, req, res)) return;
|
||||
try {
|
||||
const existing = DatabaseService.getInstance().getSSOConfig(provider);
|
||||
const wasEnabled = existing?.enabled === 1;
|
||||
if (wouldRemoveLastProvider(provider, wasEnabled)) {
|
||||
res.status(400).json({
|
||||
error: 'Cannot delete the last SSO provider while SSO-only mode is active. Switch to Local and SSO first, or use the emergency CLI.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
SSOService.getInstance().deleteProviderConfig(provider);
|
||||
console.log(`[SSO] Config deleted: ${sanitizeForLog(provider)}`);
|
||||
res.json({ success: true, message: 'SSO configuration deleted' });
|
||||
|
||||
@@ -17,13 +17,17 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
|
||||
import DockerController, { type BulkStackInfo } from '../services/DockerController';
|
||||
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
|
||||
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
|
||||
import { UpdatePreviewService } from '../services/UpdatePreviewService';
|
||||
import {
|
||||
UpdatePreviewService,
|
||||
isAuthoritativeNegativePreview,
|
||||
buildDetectionDisabledPreview,
|
||||
} from '../services/UpdatePreviewService';
|
||||
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
|
||||
import { DriftLedgerService, type DriftTemporal } from '../services/DriftLedgerService';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { RULE_IDS } from '../services/preflight/rules';
|
||||
import { RULE_IDS, isPreflightNoteFinding } from '../services/preflight/rules';
|
||||
import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack-filter';
|
||||
import type { PreflightAckExpiryMode } from '../services/DatabaseService';
|
||||
import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector';
|
||||
@@ -56,6 +60,11 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP
|
||||
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
||||
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
import {
|
||||
ImageUpdateService,
|
||||
UPDATE_VERIFICATION_INCOMPLETE_WARNING,
|
||||
} from '../services/ImageUpdateService';
|
||||
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
|
||||
import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution';
|
||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||
@@ -1408,6 +1417,10 @@ stacksRouter.post('/:stackName/preflight/acknowledgements', async (req: Request,
|
||||
res.status(400).json({ error: 'ruleId must be a known Compose Doctor rule id' });
|
||||
return;
|
||||
}
|
||||
if (isPreflightNoteFinding(ruleId)) {
|
||||
res.status(400).json({ error: 'Informational notes cannot be acknowledged' });
|
||||
return;
|
||||
}
|
||||
const serviceRaw = body.service == null || body.service === ''
|
||||
? null
|
||||
: String(body.service).trim();
|
||||
@@ -2243,6 +2256,14 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
|
||||
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
// Anatomy and other GET consumers must not contact registries while
|
||||
// node-scoped detection is off.
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
res.json(buildDetectionDisabledPreview(stackName));
|
||||
return;
|
||||
}
|
||||
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
|
||||
// GET consumers never mutate persisted scanner state.
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
res.json(preview);
|
||||
} catch (error) {
|
||||
@@ -2251,6 +2272,50 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
try {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
// No registry I/O and no sticky reconcile on a synthetic disabled preview.
|
||||
res.json({ ...buildDetectionDisabledPreview(stackName), reconciled: false });
|
||||
return;
|
||||
}
|
||||
// Snapshot write-generation watermarks before the read-only preview so a
|
||||
// later clear can erase older confirmed/sticky rows without racing a
|
||||
// scanner that reserved or rewrote the row after this observation.
|
||||
const imageUpdates = ImageUpdateService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const observedMemoryGeneration = imageUpdates.peekStackWriteGeneration(req.nodeId, stackName);
|
||||
const observedRowGeneration = db.getStackUpdateWriteGeneration(req.nodeId, stackName);
|
||||
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
|
||||
let reconciled = false;
|
||||
if (isAuthoritativeNegativePreview(preview)) {
|
||||
const clearResult = await imageUpdates.commitPreviewClear(
|
||||
req.nodeId,
|
||||
stackName,
|
||||
observedMemoryGeneration,
|
||||
observedRowGeneration,
|
||||
);
|
||||
if (clearResult === 'cleared') {
|
||||
reconciled = true;
|
||||
invalidateFleetUpdateCache();
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: req.nodeId,
|
||||
stackName,
|
||||
action: 'update-status-reconciled',
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ ...preview, reconciled });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Update preview reconcile failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
|
||||
res.status(500).json({ error: 'Failed to compute update preview' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
||||
@@ -2270,7 +2335,27 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
{ nodeId: req.nodeId, stackName, target: { scope: 'stack' }, trigger: 'manual', actor: req.user?.username ?? null },
|
||||
{ atomic, terminalWs: getTerminalWs(req.get(DEPLOY_SESSION_HEADER)) },
|
||||
);
|
||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||
// Health observation starts immediately after Compose; registry recheck is
|
||||
// isolated so a verification failure cannot turn Compose success into 500.
|
||||
ok = true;
|
||||
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', req.user?.username ?? null);
|
||||
const recoveryId = orchResult.kind === 'stack_compose_done' ? orchResult.recoveryId : null;
|
||||
linkStackUpdateRecoveryGate(recoveryId, healthGateId);
|
||||
|
||||
let recheckWarning: string | undefined;
|
||||
try {
|
||||
const recheck = await ImageUpdateService.getInstance().recheckStack(req.nodeId, stackName);
|
||||
if (recheck.warning) recheckWarning = recheck.warning;
|
||||
} catch (recheckErr) {
|
||||
console.warn(
|
||||
'[Stacks] Post-update recheck failed for %s: %s',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(recheckErr, 'unknown')),
|
||||
);
|
||||
recheckWarning = UPDATE_VERIFICATION_INCOMPLETE_WARNING;
|
||||
}
|
||||
|
||||
invalidateFleetUpdateCache();
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
@@ -2282,11 +2367,11 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
||||
});
|
||||
dlog(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
|
||||
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
|
||||
ok = true;
|
||||
const healthGateId = HealthGateService.getInstance().beginStack(req.nodeId, stackName, 'update', req.user?.username ?? null);
|
||||
const recoveryId = orchResult.kind === 'stack_compose_done' ? orchResult.recoveryId : null;
|
||||
linkStackUpdateRecoveryGate(recoveryId, healthGateId);
|
||||
res.json({ status: 'Update completed', healthGateId });
|
||||
res.json({
|
||||
status: 'Update completed',
|
||||
healthGateId,
|
||||
...(recheckWarning ? { recheckWarning } : {}),
|
||||
});
|
||||
notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system');
|
||||
if (!skipScan) {
|
||||
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
|
||||
|
||||
@@ -64,6 +64,9 @@ export class CacheService {
|
||||
|
||||
private readonly store = new Map<string, CacheEntry<unknown>>();
|
||||
private readonly inflight = new Map<string, Promise<unknown>>();
|
||||
/** Per-key write generation. Bumped on invalidate so an older in-flight
|
||||
* fetcher cannot commit after the key was intentionally cleared. */
|
||||
private readonly generations = new Map<string, number>();
|
||||
private readonly stats = new Map<string, NamespaceStats>();
|
||||
|
||||
public static getInstance(): CacheService {
|
||||
@@ -121,13 +124,21 @@ export class CacheService {
|
||||
return { value, outcome: 'inflight' };
|
||||
}
|
||||
|
||||
// Capture generation before the fetch so invalidate() during the wait can
|
||||
// supersede this writer's store commit (and drop the inflight slot so a
|
||||
// later caller starts a fresh computation).
|
||||
const generation = this.currentGeneration(key);
|
||||
|
||||
// This caller owns the computation; the closure records whether it ended
|
||||
// as a fresh compute or a stale fallback, read after the promise settles.
|
||||
let outcome: CacheFetchOutcome = 'computed';
|
||||
const inflightSelf: { promise: Promise<T> | null } = { promise: null };
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const value = await fetcher();
|
||||
this.set(key, value, ttlMs);
|
||||
if (this.currentGeneration(key) === generation) {
|
||||
this.set(key, value, ttlMs);
|
||||
}
|
||||
return value;
|
||||
} catch (err) {
|
||||
if (existing) {
|
||||
@@ -137,9 +148,14 @@ export class CacheService {
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.inflight.delete(key);
|
||||
// Only clear the inflight slot if we still own it. invalidate() may
|
||||
// have already deleted this entry and allowed a newer owner.
|
||||
if (this.inflight.get(key) === inflightSelf.promise) {
|
||||
this.inflight.delete(key);
|
||||
}
|
||||
}
|
||||
})();
|
||||
inflightSelf.promise = promise;
|
||||
|
||||
this.inflight.set(key, promise);
|
||||
const value = await promise;
|
||||
@@ -177,17 +193,22 @@ export class CacheService {
|
||||
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
/** Invalidate a single key. */
|
||||
/** Invalidate a single key and supersede any in-flight writer for it. */
|
||||
public invalidate(key: string): void {
|
||||
this.store.delete(key);
|
||||
this.inflight.delete(key);
|
||||
this.bumpGeneration(key);
|
||||
}
|
||||
|
||||
/** Invalidate every key whose namespace matches `namespace`. */
|
||||
public invalidateNamespace(namespace: string): void {
|
||||
const prefix = `${namespace}:`;
|
||||
for (const key of this.store.keys()) {
|
||||
const keys = new Set([...this.store.keys(), ...this.inflight.keys(), ...this.generations.keys()]);
|
||||
for (const key of keys) {
|
||||
if (key === namespace || key.startsWith(prefix)) {
|
||||
this.store.delete(key);
|
||||
this.inflight.delete(key);
|
||||
this.bumpGeneration(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,6 +217,7 @@ export class CacheService {
|
||||
public flush(): void {
|
||||
this.store.clear();
|
||||
this.inflight.clear();
|
||||
this.generations.clear();
|
||||
this.stats.clear();
|
||||
}
|
||||
|
||||
@@ -259,4 +281,14 @@ export class CacheService {
|
||||
if (entry.expiresAt <= now) this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private currentGeneration(key: string): number {
|
||||
return this.generations.get(key) ?? 0;
|
||||
}
|
||||
|
||||
private bumpGeneration(key: string): number {
|
||||
const next = this.currentGeneration(key) + 1;
|
||||
this.generations.set(key, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,14 @@ export const CAPABILITIES = [
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'notification-suppression-replica-retraction',
|
||||
'host-console',
|
||||
'host-console-community',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
'sso',
|
||||
'authentication-mode',
|
||||
'api-tokens',
|
||||
'users',
|
||||
'registries',
|
||||
@@ -83,6 +85,14 @@ export const HOST_CONSOLE_COMMUNITY_CAPABILITY = 'host-console-community' as con
|
||||
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
|
||||
'notification-suppression-schedule' as const satisfies Capability;
|
||||
|
||||
/**
|
||||
* Remotes that accept hub-authored `{ kind, source_updated_at }` on replica DELETE
|
||||
* and persist versioned tombstones. Without this, hubs must not send recoverable
|
||||
* soft-cleanup DELETEs (pre-tombstone remotes would bare-delete with no guard).
|
||||
*/
|
||||
export const NOTIFICATION_SUPPRESSION_REPLICA_RETRACTION_CAPABILITY =
|
||||
'notification-suppression-replica-retraction' as const satisfies Capability;
|
||||
|
||||
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
|
||||
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import { parseComposeDependencies } from '../helpers/composeDependencyParse';
|
||||
import { parseEffectiveModel, type EffectiveModel } from './preflight/effectiveModel';
|
||||
import { getExposureContext } from './network/exposureContext';
|
||||
import type { ExposureIntent } from './network/types';
|
||||
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID } from './preflight/rules';
|
||||
import { runRules, SEVERITY_RANK, RULE_IDS, RENDER_FAILED_RULE_ID, isPreflightNoteFinding } from './preflight/rules';
|
||||
import type {
|
||||
BindCheck, NodePortBinding, PreflightContext, PreflightFinding, PreflightReport, PreflightSeverity, PreflightStatus, MissingEnvFile,
|
||||
ServiceHealthcheckEvidence,
|
||||
} from './preflight/types';
|
||||
import { collectServiceHealthcheckEvidence } from './healthcheck/collectServiceHealthcheckEvidence';
|
||||
import { applyPreflightAcknowledgements, parseServiceImages } from '../utils/preflight-ack-filter';
|
||||
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
@@ -38,6 +40,7 @@ function sortFindings(findings: PreflightFinding[]): PreflightFinding[] {
|
||||
function highestOf(findings: PreflightFinding[]): PreflightSeverity | null {
|
||||
let best: PreflightSeverity | null = null;
|
||||
for (const f of findings) {
|
||||
if (isPreflightNoteFinding(f.ruleId)) continue;
|
||||
if (best === null || SEVERITY_RANK[f.severity] > SEVERITY_RANK[best]) best = f.severity;
|
||||
}
|
||||
return best;
|
||||
@@ -47,8 +50,10 @@ function activeFields(
|
||||
renderable: boolean,
|
||||
findings: PreflightFinding[],
|
||||
): Pick<PreflightReport, 'activeStatus' | 'activeHighestSeverity' | 'activeCount' | 'acknowledgedCount'> {
|
||||
const active = findings.filter(f => !f.acknowledged);
|
||||
const acknowledgedCount = findings.length - active.length;
|
||||
// Notes stay in `findings` for display but do not affect All Clear or active severity.
|
||||
const issueFindings = findings.filter(f => !isPreflightNoteFinding(f.ruleId));
|
||||
const active = issueFindings.filter(f => !f.acknowledged);
|
||||
const acknowledgedCount = issueFindings.length - active.length;
|
||||
const activeHighestSeverity = highestOf(active);
|
||||
const activeStatus: PreflightStatus = !renderable
|
||||
? 'unrenderable'
|
||||
@@ -265,7 +270,12 @@ export class ComposeDoctorService {
|
||||
}
|
||||
|
||||
const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName);
|
||||
const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : [];
|
||||
const [bindChecks, healthchecks] = await Promise.all([
|
||||
model ? this.resolveBindChecks(model, baseDir) : Promise.resolve([] as BindCheck[]),
|
||||
model
|
||||
? collectServiceHealthcheckEvidence(nodeId, stackName, model, nodeStateAvailable)
|
||||
: Promise.resolve({} as Record<string, ServiceHealthcheckEvidence>),
|
||||
]);
|
||||
const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls, exposureAvailable } = this.exposureState(nodeId, stackName);
|
||||
const selfStack = await isSelfStack(stackName);
|
||||
|
||||
@@ -292,6 +302,7 @@ export class ComposeDoctorService {
|
||||
hasAccessUrls,
|
||||
exposureAvailable,
|
||||
isSelfStack: selfStack,
|
||||
healthchecks,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,20 @@ function parseServicesJson(raw: string | null | undefined): StackServiceStatus[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Write generation embedded in services_json; 0 when missing or unreadable. */
|
||||
function parseServicesJsonGeneration(raw: string | null | undefined): number {
|
||||
if (!raw) return 0;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; generation?: unknown };
|
||||
if (parsed?.version !== SERVICES_JSON_VERSION) return 0;
|
||||
return typeof parsed.generation === 'number' && Number.isFinite(parsed.generation)
|
||||
? parsed.generation
|
||||
: 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
|
||||
return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
|
||||
}
|
||||
@@ -700,6 +714,10 @@ export interface ScheduledTask {
|
||||
prune_targets: string | null;
|
||||
target_services: string | null;
|
||||
prune_label_filter: string | null;
|
||||
/** Optional dynamic target selector; currently only 'stack-label'. */
|
||||
selector_type: string | null;
|
||||
/** Selector payload (e.g. Stack Label name when selector_type is stack-label). */
|
||||
selector_value: string | null;
|
||||
delete_after_run?: number;
|
||||
// Absolute epoch-ms fire time for a one-time ('once') schedule. A 5-field
|
||||
// cron has no year field, so the chosen instant (including year) is persisted
|
||||
@@ -752,6 +770,52 @@ export interface NotificationRoute {
|
||||
|
||||
export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
/** Hub-authored replica retraction: permanent never clears; recoverable is LWW-ordered. */
|
||||
export type NotificationSuppressionRetractionKind = 'permanent' | 'recoverable';
|
||||
|
||||
export interface NotificationSuppressionRetraction {
|
||||
kind: NotificationSuppressionRetractionKind;
|
||||
/** Hub rule.updated_at at retraction time; compared only to hub versions, never receiver clocks. */
|
||||
source_updated_at: number;
|
||||
}
|
||||
|
||||
export interface NotificationSuppressionRuleTombstone {
|
||||
id: number;
|
||||
deleted_at: number;
|
||||
kind: NotificationSuppressionRetractionKind;
|
||||
source_updated_at: number;
|
||||
}
|
||||
|
||||
export type SuppressionReplicaWriteOutcome =
|
||||
| 'applied'
|
||||
| 'ignored_stale'
|
||||
| 'ignored_permanent_tombstone'
|
||||
| 'ignored_recoverable_watermark';
|
||||
|
||||
export type SuppressionReplicaDeleteOutcome = 'applied' | 'ignored_stale';
|
||||
|
||||
export interface NotificationSuppressionPendingRetraction {
|
||||
rule_id: number;
|
||||
node_id: number;
|
||||
kind: NotificationSuppressionRetractionKind;
|
||||
source_updated_at: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/** Fail closed: anything other than recoverable is permanent. */
|
||||
function normalizeSuppressionRetractionKind(kind: unknown): NotificationSuppressionRetractionKind {
|
||||
return kind === 'recoverable' ? 'recoverable' : 'permanent';
|
||||
}
|
||||
|
||||
/** Coerce tombstone watermarks for merge/read; invalid values become 0. */
|
||||
function safeTombstoneSourceUpdatedAt(value: unknown): number {
|
||||
const n = Number(value);
|
||||
return Number.isSafeInteger(n) ? n : 0;
|
||||
}
|
||||
|
||||
export interface NotificationSuppressionRule {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -1876,6 +1940,8 @@ export class DatabaseService {
|
||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'selector_type', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'selector_value', 'TEXT DEFAULT NULL');
|
||||
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
|
||||
maybeAddCol('scheduled_tasks', 'run_at', 'INTEGER DEFAULT NULL');
|
||||
|
||||
@@ -1952,16 +2018,27 @@ export class DatabaseService {
|
||||
stmt.run('cve_intel_enabled', '1');
|
||||
stmt.run('mesh_auto_recreate', '0');
|
||||
stmt.run('prune_on_update', '1');
|
||||
stmt.run('reclaim_hero', '1');
|
||||
// Managed by /api/sso/auth-mode, not the generic /api/settings route
|
||||
// (activation needs safety validation).
|
||||
stmt.run('authentication_mode', 'local_and_sso');
|
||||
stmt.run('reclaim_hero', '0');
|
||||
stmt.run('health_gate_enabled', '1');
|
||||
stmt.run('health_gate_window_seconds', '90');
|
||||
stmt.run('image_update_check_interval_minutes', '120');
|
||||
stmt.run('image_update_check_mode', 'interval');
|
||||
stmt.run('image_update_check_cron', '');
|
||||
stmt.run('image_update_sidebar_indicators', '1');
|
||||
// Opt-out for background registry polling. Default on so upgrades keep
|
||||
// current behavior; missing key is also treated as enabled at read time.
|
||||
stmt.run('image_update_checks_enabled', '1');
|
||||
stmt.run('notification_dispatch_retries', '0');
|
||||
stmt.run('env_block_deploy_on_missing_required', '0');
|
||||
stmt.run('auto_create_missing_external_networks', '0');
|
||||
// Silently extend an actively-used session's cookie instead of hard
|
||||
// expiring it. On by default (matches how most session-based web apps
|
||||
// behave); admins who want a strict absolute session ceiling can turn
|
||||
// it off in Settings > Users.
|
||||
stmt.run('session_sliding_refresh', '1');
|
||||
|
||||
// Seed the default local node if none exists
|
||||
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
|
||||
@@ -2252,6 +2329,39 @@ export class DatabaseService {
|
||||
);
|
||||
`);
|
||||
this.tryAddColumn('notification_suppression_rules', 'schedule', 'TEXT NULL');
|
||||
// kind + source_updated_at: hub-authored retract ordering. Legacy rows stay
|
||||
// permanent (fail closed); source_updated_at backfills from deleted_at for
|
||||
// audit continuity but is never compared to receiver wall clocks on write paths.
|
||||
this.tryAddColumn(
|
||||
'notification_suppression_rule_tombstones',
|
||||
'kind',
|
||||
"TEXT NOT NULL DEFAULT 'permanent'",
|
||||
);
|
||||
this.tryAddColumn(
|
||||
'notification_suppression_rule_tombstones',
|
||||
'source_updated_at',
|
||||
'INTEGER',
|
||||
);
|
||||
this.db.prepare(
|
||||
`UPDATE notification_suppression_rule_tombstones
|
||||
SET source_updated_at = deleted_at
|
||||
WHERE source_updated_at IS NULL`,
|
||||
).run();
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_suppression_pending_retractions (
|
||||
rule_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
source_updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
PRIMARY KEY (rule_id, node_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_supp_pending_retract_node
|
||||
ON notification_suppression_pending_retractions(node_id);
|
||||
`);
|
||||
}
|
||||
|
||||
private migrateNotificationHistoryContext(): void {
|
||||
@@ -2926,7 +3036,32 @@ export class DatabaseService {
|
||||
return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
|
||||
private insertNotificationSuppressionRuleReplicaRow(
|
||||
rule: NotificationSuppressionRule,
|
||||
scheduleJson: string | null,
|
||||
): void {
|
||||
this.db.prepare(
|
||||
`INSERT INTO notification_suppression_rules
|
||||
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
rule.id,
|
||||
rule.name,
|
||||
rule.node_id ?? null,
|
||||
JSON.stringify(rule.stack_patterns),
|
||||
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
|
||||
rule.categories ? JSON.stringify(rule.categories) : null,
|
||||
rule.levels ? JSON.stringify(rule.levels) : null,
|
||||
rule.applies_to,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.expires_at ?? null,
|
||||
scheduleJson,
|
||||
rule.created_at,
|
||||
rule.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): SuppressionReplicaWriteOutcome {
|
||||
const scheduleJson = rule.schedule ? JSON.stringify(rule.schedule) : null;
|
||||
const existing = this.getNotificationSuppressionRule(rule.id);
|
||||
if (existing) {
|
||||
@@ -2937,7 +3072,7 @@ export class DatabaseService {
|
||||
`[DatabaseService] Ignoring stale suppression replica write for rule id=${sanitizeForLog(rule.id)} ` +
|
||||
`(incoming updated_at=${sanitizeForLog(rule.updated_at)} <= stored updated_at=${sanitizeForLog(existing.updated_at)})`,
|
||||
);
|
||||
return;
|
||||
return 'ignored_stale';
|
||||
}
|
||||
this.db.prepare(
|
||||
`UPDATE notification_suppression_rules SET
|
||||
@@ -2958,37 +3093,46 @@ export class DatabaseService {
|
||||
rule.updated_at,
|
||||
rule.id,
|
||||
);
|
||||
return;
|
||||
return 'applied';
|
||||
}
|
||||
const tombstone = this.db.prepare(
|
||||
'SELECT deleted_at FROM notification_suppression_rule_tombstones WHERE id = ?',
|
||||
).get(rule.id) as { deleted_at: number } | undefined;
|
||||
if (tombstone) {
|
||||
console.warn(
|
||||
`[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` +
|
||||
`this id was deleted at ${sanitizeForLog(tombstone.deleted_at)} and must not be recreated`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.db.prepare(
|
||||
`INSERT INTO notification_suppression_rules
|
||||
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
rule.id,
|
||||
rule.name,
|
||||
rule.node_id ?? null,
|
||||
JSON.stringify(rule.stack_patterns),
|
||||
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
|
||||
rule.categories ? JSON.stringify(rule.categories) : null,
|
||||
rule.levels ? JSON.stringify(rule.levels) : null,
|
||||
rule.applies_to,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.expires_at ?? null,
|
||||
scheduleJson,
|
||||
rule.created_at,
|
||||
rule.updated_at,
|
||||
);
|
||||
// Re-read tombstone inside the transaction so a concurrent permanent
|
||||
// retract cannot be cleared after an outer eligibility check.
|
||||
let outcome: SuppressionReplicaWriteOutcome = 'applied';
|
||||
this.transaction(() => {
|
||||
const tombstone = this.db.prepare(
|
||||
`SELECT id, deleted_at, kind, source_updated_at
|
||||
FROM notification_suppression_rule_tombstones WHERE id = ?`,
|
||||
).get(rule.id) as NotificationSuppressionRuleTombstone | undefined;
|
||||
if (tombstone) {
|
||||
const kind = normalizeSuppressionRetractionKind(tombstone.kind);
|
||||
// Keep raw Number here: invalid watermarks must fail closed (block recreate),
|
||||
// not coerce to 0 the way safeTombstoneSourceUpdatedAt does for merges/reads.
|
||||
const sourceUpdatedAt = Number(tombstone.source_updated_at);
|
||||
if (kind === 'permanent') {
|
||||
console.warn(
|
||||
`[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` +
|
||||
`permanent tombstone (source_updated_at=${sanitizeForLog(sourceUpdatedAt)})`,
|
||||
);
|
||||
outcome = 'ignored_permanent_tombstone';
|
||||
return;
|
||||
}
|
||||
if (!Number.isSafeInteger(sourceUpdatedAt) || rule.updated_at <= sourceUpdatedAt) {
|
||||
console.warn(
|
||||
`[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` +
|
||||
`recoverable tombstone source_updated_at=${sanitizeForLog(sourceUpdatedAt)}; ` +
|
||||
`incoming updated_at=${sanitizeForLog(rule.updated_at)} is not newer`,
|
||||
);
|
||||
outcome = 'ignored_recoverable_watermark';
|
||||
return;
|
||||
}
|
||||
this.db.prepare(
|
||||
'DELETE FROM notification_suppression_rule_tombstones WHERE id = ?',
|
||||
).run(rule.id);
|
||||
}
|
||||
this.insertNotificationSuppressionRuleReplicaRow(rule, scheduleJson);
|
||||
outcome = 'applied';
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public updateNotificationSuppressionRule(
|
||||
@@ -3018,19 +3162,164 @@ export class DatabaseService {
|
||||
this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteNotificationSuppressionRule(id: number): number {
|
||||
const changes = this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes;
|
||||
// Fleet sync has no delivery ordering guarantee: a replica POST for this id can
|
||||
// still be in flight. Record the delete permanently (ids are AUTOINCREMENT and
|
||||
// never reused) so upsertNotificationSuppressionRuleReplica refuses to resurrect it.
|
||||
// Tombstone unconditionally, even when changes is 0: deleteRuleOnNode issues this
|
||||
// same DELETE for capability/invalid-schedule cleanup against remotes that may never
|
||||
// have received the rule yet, and a reordered POST behind that DELETE must not create it.
|
||||
/**
|
||||
* Delete a suppression rule and record a hub-authored retraction tombstone.
|
||||
* Optional retraction defaults to permanent/0 (fail closed) for one-arg callers.
|
||||
* Recoverable DELETEs that are strictly older than a stored row are ignored
|
||||
* (no durable mutation). Permanent always applies. Row delete + tombstone
|
||||
* upsert are one transaction.
|
||||
*/
|
||||
public deleteNotificationSuppressionRule(
|
||||
id: number,
|
||||
retraction: NotificationSuppressionRetraction = { kind: 'permanent', source_updated_at: 0 },
|
||||
): { changes: number; outcome: SuppressionReplicaDeleteOutcome } {
|
||||
const kind = normalizeSuppressionRetractionKind(retraction.kind);
|
||||
const sourceUpdatedAt = retraction.source_updated_at;
|
||||
|
||||
return this.transaction(() => {
|
||||
const existing = this.getNotificationSuppressionRule(id);
|
||||
if (
|
||||
kind === 'recoverable' &&
|
||||
existing &&
|
||||
existing.updated_at > sourceUpdatedAt
|
||||
) {
|
||||
console.warn(
|
||||
`[DatabaseService] Ignoring stale recoverable suppression DELETE for rule id=${sanitizeForLog(id)}: ` +
|
||||
`source_updated_at=${sanitizeForLog(sourceUpdatedAt)} < stored updated_at=${sanitizeForLog(existing.updated_at)}`,
|
||||
);
|
||||
return { changes: 0, outcome: 'ignored_stale' };
|
||||
}
|
||||
|
||||
const changes = this.db.prepare(
|
||||
'DELETE FROM notification_suppression_rules WHERE id = ?',
|
||||
).run(id).changes;
|
||||
|
||||
const prior = this.db.prepare(
|
||||
`SELECT kind, source_updated_at FROM notification_suppression_rule_tombstones WHERE id = ?`,
|
||||
).get(id) as { kind: string; source_updated_at: number } | undefined;
|
||||
|
||||
let mergedKind = kind;
|
||||
let mergedSource = sourceUpdatedAt;
|
||||
if (prior) {
|
||||
const priorKind = normalizeSuppressionRetractionKind(prior.kind);
|
||||
// Permanent wins over recoverable; watermark always takes the max.
|
||||
mergedKind =
|
||||
priorKind === 'permanent' || kind === 'permanent' ? 'permanent' : 'recoverable';
|
||||
mergedSource = Math.max(
|
||||
safeTombstoneSourceUpdatedAt(prior.source_updated_at),
|
||||
sourceUpdatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
this.db.prepare(
|
||||
`INSERT INTO notification_suppression_rule_tombstones (id, deleted_at, kind, source_updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
deleted_at = excluded.deleted_at,
|
||||
kind = excluded.kind,
|
||||
source_updated_at = excluded.source_updated_at`,
|
||||
).run(id, Date.now(), mergedKind, mergedSource);
|
||||
|
||||
return { changes, outcome: 'applied' };
|
||||
});
|
||||
}
|
||||
|
||||
public getNotificationSuppressionRuleTombstone(
|
||||
id: number,
|
||||
): NotificationSuppressionRuleTombstone | undefined {
|
||||
const row = this.db.prepare(
|
||||
`SELECT id, deleted_at, kind, source_updated_at
|
||||
FROM notification_suppression_rule_tombstones WHERE id = ?`,
|
||||
).get(id) as NotificationSuppressionRuleTombstone | undefined;
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
deleted_at: row.deleted_at,
|
||||
kind: normalizeSuppressionRetractionKind(row.kind),
|
||||
// Keep || 0 (not safeTombstoneSourceUpdatedAt): public reads historically
|
||||
// surface any truthy Number() result; merge/write paths coerce separately.
|
||||
source_updated_at: Number(row.source_updated_at) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public upsertNotificationSuppressionPendingRetraction(row: {
|
||||
rule_id: number;
|
||||
node_id: number;
|
||||
kind: NotificationSuppressionRetractionKind;
|
||||
source_updated_at: number;
|
||||
last_error?: string;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
const kind = normalizeSuppressionRetractionKind(row.kind);
|
||||
const prior = this.db.prepare(
|
||||
`SELECT kind, source_updated_at, attempts FROM notification_suppression_pending_retractions
|
||||
WHERE rule_id = ? AND node_id = ?`,
|
||||
).get(row.rule_id, row.node_id) as { kind: string; source_updated_at: number; attempts: number } | undefined;
|
||||
let mergedKind = kind;
|
||||
let mergedSource = row.source_updated_at;
|
||||
let attempts = 1;
|
||||
if (prior) {
|
||||
const priorKind = normalizeSuppressionRetractionKind(prior.kind);
|
||||
mergedKind =
|
||||
priorKind === 'permanent' || kind === 'permanent' ? 'permanent' : 'recoverable';
|
||||
mergedSource = Math.max(
|
||||
safeTombstoneSourceUpdatedAt(prior.source_updated_at),
|
||||
row.source_updated_at,
|
||||
);
|
||||
attempts = (prior.attempts || 0) + 1;
|
||||
}
|
||||
this.db.prepare(
|
||||
`INSERT INTO notification_suppression_rule_tombstones (id, deleted_at) VALUES (?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET deleted_at = excluded.deleted_at`,
|
||||
).run(id, Date.now());
|
||||
return changes;
|
||||
`INSERT INTO notification_suppression_pending_retractions
|
||||
(rule_id, node_id, kind, source_updated_at, created_at, updated_at, attempts, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(rule_id, node_id) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
source_updated_at = excluded.source_updated_at,
|
||||
updated_at = excluded.updated_at,
|
||||
attempts = excluded.attempts,
|
||||
last_error = excluded.last_error`,
|
||||
).run(
|
||||
row.rule_id,
|
||||
row.node_id,
|
||||
mergedKind,
|
||||
mergedSource,
|
||||
now,
|
||||
now,
|
||||
attempts,
|
||||
row.last_error ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public deleteNotificationSuppressionPendingRetraction(ruleId: number, nodeId: number): void {
|
||||
this.db.prepare(
|
||||
'DELETE FROM notification_suppression_pending_retractions WHERE rule_id = ? AND node_id = ?',
|
||||
).run(ruleId, nodeId);
|
||||
}
|
||||
|
||||
public listNotificationSuppressionPendingRetractions(
|
||||
nodeId?: number,
|
||||
): NotificationSuppressionPendingRetraction[] {
|
||||
const rows = (
|
||||
nodeId == null
|
||||
? this.db.prepare(
|
||||
`SELECT * FROM notification_suppression_pending_retractions ORDER BY updated_at ASC`,
|
||||
).all()
|
||||
: this.db.prepare(
|
||||
`SELECT * FROM notification_suppression_pending_retractions
|
||||
WHERE node_id = ? ORDER BY updated_at ASC`,
|
||||
).all(nodeId)
|
||||
) as Array<Record<string, unknown>>;
|
||||
return rows.map((r) => ({
|
||||
rule_id: r.rule_id as number,
|
||||
node_id: r.node_id as number,
|
||||
kind: normalizeSuppressionRetractionKind(r.kind),
|
||||
source_updated_at: Number(r.source_updated_at) || 0,
|
||||
created_at: r.created_at as number,
|
||||
updated_at: r.updated_at as number,
|
||||
attempts: (r.attempts as number) || 0,
|
||||
last_error: (r.last_error as string | null) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Global Settings ---
|
||||
@@ -4436,6 +4725,9 @@ export class DatabaseService {
|
||||
this.deleteRoleAssignmentsByResource('node', String(id));
|
||||
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id);
|
||||
this.db.prepare(
|
||||
'DELETE FROM notification_suppression_pending_retractions WHERE node_id = ?',
|
||||
).run(id);
|
||||
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
@@ -4628,6 +4920,13 @@ export class DatabaseService {
|
||||
).run(nodeId, stackName, lastError, checkedAt, servicesJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw has_update map for scanner retention and notification transitions.
|
||||
* Ignores check_status: a partial/failed row with has_update=1 stays true
|
||||
* so ImageUpdateService can preserve sticky state across incomplete runs.
|
||||
* API/Fleet consumers that need "confirmed update" must use
|
||||
* getConfirmedStackUpdateStatus instead.
|
||||
*/
|
||||
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
|
||||
const rows = nodeId !== undefined
|
||||
? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
|
||||
@@ -4639,10 +4938,32 @@ export class DatabaseService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed-update projection for GET /api/image-updates and Fleet local
|
||||
* aggregation. True only when has_update=1 and the latest check completed
|
||||
* successfully (check_status='ok'). Partial/failed retained rows are false.
|
||||
*/
|
||||
public getConfirmedStackUpdateStatus(nodeId?: number): Record<string, boolean> {
|
||||
const rows = nodeId !== undefined
|
||||
? this.db.prepare(
|
||||
`SELECT stack_name, has_update, check_status FROM stack_update_status WHERE node_id = ?`
|
||||
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null }>
|
||||
: this.db.prepare(
|
||||
`SELECT stack_name, has_update, check_status FROM stack_update_status`
|
||||
).all() as Array<{ stack_name: string; has_update: number; check_status: string | null }>;
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const row of rows) {
|
||||
// Match getNodeUpdateSummary / frontend isConfirmedImageUpdate:
|
||||
// null check_status is treated as ok (legacy rows).
|
||||
result[row.stack_name] = row.has_update === 1 && (row.check_status ?? 'ok') === 'ok';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich per-stack update status (hasUpdate + check outcome + reason) for the
|
||||
* sidebar/readiness UI. GET /api/image-updates stays the boolean map so the
|
||||
* cross-version fleet aggregation contract is unaffected.
|
||||
* sidebar/readiness UI. Confirmed boolean maps use getConfirmedStackUpdateStatus;
|
||||
* raw prior state for the scanner stays on getStackUpdateStatus.
|
||||
*/
|
||||
public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
|
||||
const rows = this.db.prepare(
|
||||
@@ -4675,8 +4996,28 @@ export class DatabaseService {
|
||||
return parseServicesJson(row?.services_json);
|
||||
}
|
||||
|
||||
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
/**
|
||||
* Scanner write generation stored with services_json for this stack.
|
||||
* Used by preview reconcile to skip clearing a row written after the
|
||||
* preview observation watermark. Returns 0 when missing or unreadable.
|
||||
*/
|
||||
public getStackUpdateWriteGeneration(nodeId: number, stackName: string): number {
|
||||
const row = this.db.prepare(
|
||||
'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
|
||||
).get(nodeId, stackName) as { services_json: string | null } | undefined;
|
||||
return parseServicesJsonGeneration(row?.services_json);
|
||||
}
|
||||
|
||||
/** Deletes the full update row (aggregate + services_json). Returns deleted row count. */
|
||||
public clearStackUpdateStatus(nodeId: number, stackName: string): number {
|
||||
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/** Deletes every update row for a node. Returns deleted row count. */
|
||||
public clearAllStackUpdateStatus(nodeId: number): number {
|
||||
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// --- Stack Scan Attempts ---
|
||||
@@ -4718,7 +5059,10 @@ export class DatabaseService {
|
||||
|
||||
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
|
||||
return this.db.prepare(
|
||||
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
|
||||
`SELECT node_id, SUM(has_update) as stacks_with_updates
|
||||
FROM stack_update_status
|
||||
WHERE has_update = 1 AND COALESCE(check_status, 'ok') = 'ok'
|
||||
GROUP BY node_id`
|
||||
).all() as Array<{ node_id: number; stacks_with_updates: number }>;
|
||||
}
|
||||
|
||||
@@ -5703,13 +6047,14 @@ export class DatabaseService {
|
||||
|
||||
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, selector_type, selector_value, delete_after_run, run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
task.name, task.target_type, task.target_id, task.node_id,
|
||||
task.action, task.cron_expression, task.enabled, task.created_by,
|
||||
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
|
||||
task.last_status, task.last_error, task.prune_targets, task.target_services,
|
||||
task.prune_label_filter, task.delete_after_run ?? 0, task.run_at ?? null
|
||||
task.prune_label_filter, task.selector_type ?? null, task.selector_value ?? null,
|
||||
task.delete_after_run ?? 0, task.run_at ?? null
|
||||
);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
@@ -5726,6 +6071,8 @@ export class DatabaseService {
|
||||
last_status: updates.last_status, last_error: updates.last_error,
|
||||
prune_targets: updates.prune_targets, target_services: updates.target_services,
|
||||
prune_label_filter: updates.prune_label_filter,
|
||||
selector_type: updates.selector_type,
|
||||
selector_value: updates.selector_value,
|
||||
delete_after_run: updates.delete_after_run,
|
||||
run_at: updates.run_at,
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type PruneTarget,
|
||||
} from './prunePlan';
|
||||
import type { NetworkingNetworkBase } from './network/networkingTypes';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -2230,8 +2230,12 @@ class DockerController {
|
||||
try {
|
||||
const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
|
||||
containers = lines.map(line => JSON.parse(line) as ComposePsContainer);
|
||||
} catch (innerError) {
|
||||
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
|
||||
} catch {
|
||||
const detail = stderr || (parseError as Error).message || 'unparseable compose ps output';
|
||||
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
|
||||
// Fail closed: garbage stdout is not a successful empty ps. Callers must not
|
||||
// treat this as "no containers" and run name-matched orphan removal.
|
||||
throw new Error(`docker compose ps returned unparseable JSON: ${detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2242,6 +2246,9 @@ class DockerController {
|
||||
* Legacy orphan containers that Compose ps cannot see but would block a deploy
|
||||
* (wrong project labels). When Compose already manages the stack, returns [] so
|
||||
* deploy can rely on selective `compose up` recreation.
|
||||
* A thrown `compose ps` returns [] (fail closed): name-matched fallback must not
|
||||
* run, because those IDs may still be healthy Compose runtimes that deploy would
|
||||
* then destroy before `compose up` (which often fails for the same reason).
|
||||
*/
|
||||
public async getLegacyOrphanContainersByStack(stackName: string): Promise<Array<{ Id: string }>> {
|
||||
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
|
||||
@@ -2249,26 +2256,36 @@ class DockerController {
|
||||
list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0)
|
||||
.map((c) => ({ Id: c.Id }));
|
||||
|
||||
let composeContainers: ComposePsContainer[];
|
||||
try {
|
||||
const composeContainers = await this.fetchComposePsContainers(stackName, stackDir);
|
||||
if (composeContainers.length > 0) return [];
|
||||
return toIds(await this.smartFallback(stackName, stackDir));
|
||||
composeContainers = await this.fetchComposePsContainers(stackName, stackDir);
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & { stderr?: string };
|
||||
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
|
||||
const detail = execError.stderr || mapped.message;
|
||||
const detail = execError.stderr || mapped.message || getErrorMessage(error, 'docker compose ps failed');
|
||||
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
|
||||
try {
|
||||
return toIds(await this.smartFallback(stackName, stackDir));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Fail closed: do not run smartFallback (see method JSDoc).
|
||||
return [];
|
||||
}
|
||||
if (composeContainers.length > 0) return [];
|
||||
// Empty successful ps: only then may smartFallback report leftovers (name + stack-dir evidence).
|
||||
try {
|
||||
return toIds(await this.smartFallback(stackName, stackDir));
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
'Smart Fallback failed for %s:',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(fallbackError, 'smartFallback failed')),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Result API for full-stack updates. Never converts Compose-ps + fallback
|
||||
* failure into empty success. Compose-managed containers are never orphan IDs.
|
||||
* A thrown `compose ps` is classification_failed (fail closed): name-matched
|
||||
* fallback must not run, because those IDs may still be healthy Compose runtimes.
|
||||
*/
|
||||
public async classifyLegacyOrphansForUpdate(
|
||||
stackName: string,
|
||||
@@ -2302,18 +2319,15 @@ class DockerController {
|
||||
const composeContainers = await this.fetchComposePsContainers(stackName, stackDir);
|
||||
// Compose already manages this stack: no legacy orphan cleanup (same as deploy).
|
||||
if (composeContainers.length > 0) return { status: 'none' };
|
||||
// Empty successful ps: only then may smartFallback report leftovers (name + stack-dir evidence).
|
||||
return await fallbackOrphans();
|
||||
} catch (error) {
|
||||
const execError = error as NodeJS.ErrnoException & { stderr?: string };
|
||||
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
|
||||
const detail = execError.stderr || mapped.message || getErrorMessage(error, 'docker compose ps failed');
|
||||
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
|
||||
// Unlike getLegacyOrphanContainersByStack, never convert dual failure into empty success.
|
||||
const fallback = await fallbackOrphans();
|
||||
if (fallback.status === 'classification_failed') {
|
||||
return { status: 'classification_failed', error: String(detail) };
|
||||
}
|
||||
return fallback;
|
||||
// Fail closed: do not run smartFallback (see method JSDoc).
|
||||
return { status: 'classification_failed', error: String(detail) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2349,11 +2363,6 @@ class DockerController {
|
||||
});
|
||||
return await this.enrichContainers(mapped);
|
||||
}
|
||||
|
||||
// SMART FALLBACK: Trigger when docker compose ps returns empty
|
||||
// This handles legacy containers with incorrect project labels
|
||||
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
|
||||
|
||||
} catch (error) {
|
||||
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file,
|
||||
// or host under memory pressure causing posix_spawn to fail with ENOMEM,
|
||||
@@ -2362,9 +2371,23 @@ class DockerController {
|
||||
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
|
||||
const detail = execError.stderr || mapped.message;
|
||||
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
|
||||
}
|
||||
|
||||
// Try smart fallback even on error
|
||||
// Empty successful ps or compose error: soft fallback for UI listing (never break the page).
|
||||
return this.listViaSoftSmartFallback(stackName, stackDir);
|
||||
}
|
||||
|
||||
/** UI listing path: smartFallback, or empty list if it throws. */
|
||||
private async listViaSoftSmartFallback(stackName: string, stackDir: string) {
|
||||
try {
|
||||
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
'Smart Fallback failed for %s:',
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(fallbackError, 'smartFallback failed')),
|
||||
);
|
||||
return this.enrichContainers([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2388,95 +2411,116 @@ class DockerController {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when container labels bind this runtime to `stackDir` (working_dir or
|
||||
* config_files). Name match alone is not enough for orphan removal.
|
||||
*/
|
||||
private static containerHasStackDirEvidence(
|
||||
labels: Record<string, string> | undefined,
|
||||
stackDir: string,
|
||||
): boolean {
|
||||
if (!labels) return false;
|
||||
const resolvedStackDir = path.resolve(stackDir);
|
||||
const pathKey = (p: string) =>
|
||||
process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
|
||||
const stackKey = pathKey(resolvedStackDir);
|
||||
|
||||
const workingDir = labels['com.docker.compose.project.working_dir'];
|
||||
if (workingDir && pathKey(workingDir) === stackKey) return true;
|
||||
|
||||
const firstFile = labels['com.docker.compose.project.config_files']?.split(',')[0]?.trim();
|
||||
if (!firstFile) return false;
|
||||
const fileKey = pathKey(firstFile);
|
||||
return fileKey === stackKey || fileKey.startsWith(stackKey + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart Fallback: Find legacy containers by parsing compose YAML definitions.
|
||||
* This handles containers that were deployed with incorrect project labels
|
||||
* that cause `docker compose ps` to ignore them.
|
||||
* Handles containers with incorrect project labels that `docker compose ps`
|
||||
* ignores. Name match alone is insufficient: a container must also have
|
||||
* working_dir or config_files evidence for this stack directory. Errors
|
||||
* propagate to callers (classify fail-closes; UI listing soft-catches).
|
||||
*/
|
||||
private async smartFallback(stackName: string, stackDir: string): Promise<any[]> {
|
||||
try {
|
||||
// 1. Flexible Compose File Discovery
|
||||
// Try multiple valid compose file names
|
||||
const composeFileNames = COMPOSE_FILE_NAMES;
|
||||
let yamlContent: string | null = null;
|
||||
if (!isValidStackName(stackName)) {
|
||||
throw new Error('Invalid stack path');
|
||||
}
|
||||
// Canonical inline js/path-injection barrier at the fs.readFile sink below.
|
||||
// CodeQL credits neither wrapped isPathWithinBase nor a barrier separated
|
||||
// from the sink; resolve under the compose root and require containment.
|
||||
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(this.nodeId));
|
||||
const resolvedStackDir = path.resolve(baseResolved, stackName);
|
||||
if (!resolvedStackDir.startsWith(baseResolved + path.sep)) {
|
||||
throw new Error('Invalid stack path');
|
||||
}
|
||||
// Prefer the validated path over the caller-provided stackDir.
|
||||
stackDir = resolvedStackDir;
|
||||
|
||||
for (const fileName of composeFileNames) {
|
||||
try {
|
||||
yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8');
|
||||
break; // Successfully read a file, stop trying
|
||||
} catch {
|
||||
// File doesn't exist, try next
|
||||
let yamlContent: string | null = null;
|
||||
for (const fileName of COMPOSE_FILE_NAMES) {
|
||||
try {
|
||||
const composePath = path.resolve(stackDir, fileName);
|
||||
if (!composePath.startsWith(stackDir + path.sep)) {
|
||||
continue;
|
||||
}
|
||||
yamlContent = await fs.readFile(composePath, 'utf-8');
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!yamlContent) {
|
||||
// No compose file found
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedYaml = yaml.parse(yamlContent);
|
||||
|
||||
if (!parsedYaml || !parsedYaml.services) return [];
|
||||
|
||||
// 2. Extract expected container names with legacy prefix support
|
||||
const expectedNames: string[] = [];
|
||||
const nameToService = new Map<string, string>();
|
||||
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
|
||||
const config = serviceConfig as { container_name?: string };
|
||||
nameToService.set(serviceName, serviceName);
|
||||
if (config.container_name) {
|
||||
expectedNames.push(config.container_name);
|
||||
nameToService.set(config.container_name, serviceName);
|
||||
} else {
|
||||
// Standard v2 naming
|
||||
expectedNames.push(serviceName);
|
||||
expectedNames.push(`${stackName}-${serviceName}-1`);
|
||||
// Legacy project prefix catch - accounts for orphan containers
|
||||
expectedNames.push(`compose-${serviceName}-1`);
|
||||
expectedNames.push(`compose_${serviceName}_1`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Query the raw Docker daemon
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
|
||||
// 4. Match containers by name
|
||||
const fallbackContainers = allContainers.filter(container => {
|
||||
// container.Names usually looks like ['/plex']
|
||||
return container.Names.some(name => {
|
||||
const strippedName = name.replace(/^\//, '');
|
||||
return expectedNames.includes(strippedName);
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Map to the frontend interface
|
||||
return fallbackContainers.map(c => {
|
||||
const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? '';
|
||||
const labelService = c.Labels?.['com.docker.compose.service'];
|
||||
const service = (typeof labelService === 'string' && labelService.length > 0
|
||||
? labelService
|
||||
: nameToService.get(strippedName)) ?? '';
|
||||
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
|
||||
if (c.Ports && Array.isArray(c.Ports)) {
|
||||
Ports = c.Ports
|
||||
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
|
||||
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined }));
|
||||
}
|
||||
return {
|
||||
Id: c.Id,
|
||||
Names: c.Names,
|
||||
Service: service,
|
||||
State: c.State,
|
||||
Status: c.Status,
|
||||
Labels: c.Labels,
|
||||
Ports
|
||||
};
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError)));
|
||||
return [];
|
||||
}
|
||||
if (!yamlContent) return [];
|
||||
|
||||
const parsedYaml = yaml.parse(yamlContent);
|
||||
if (!parsedYaml || !parsedYaml.services) return [];
|
||||
|
||||
const expectedNames: string[] = [];
|
||||
const nameToService = new Map<string, string>();
|
||||
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
|
||||
const config = serviceConfig as { container_name?: string };
|
||||
nameToService.set(serviceName, serviceName);
|
||||
if (config.container_name) {
|
||||
expectedNames.push(config.container_name);
|
||||
nameToService.set(config.container_name, serviceName);
|
||||
} else {
|
||||
expectedNames.push(serviceName);
|
||||
expectedNames.push(`${stackName}-${serviceName}-1`);
|
||||
// Legacy project prefixes for orphan containers
|
||||
expectedNames.push(`compose-${serviceName}-1`);
|
||||
expectedNames.push(`compose_${serviceName}_1`);
|
||||
}
|
||||
}
|
||||
|
||||
const allContainers = await this.docker.listContainers({ all: true });
|
||||
const fallbackContainers = allContainers.filter(container => {
|
||||
const nameMatch = container.Names.some(name =>
|
||||
expectedNames.includes(name.replace(/^\//, '')),
|
||||
);
|
||||
return nameMatch && DockerController.containerHasStackDirEvidence(container.Labels, stackDir);
|
||||
});
|
||||
|
||||
return fallbackContainers.map(c => {
|
||||
const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? '';
|
||||
const labelService = c.Labels?.['com.docker.compose.service'];
|
||||
const service = (typeof labelService === 'string' && labelService.length > 0
|
||||
? labelService
|
||||
: nameToService.get(strippedName)) ?? '';
|
||||
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
|
||||
if (c.Ports && Array.isArray(c.Ports)) {
|
||||
Ports = c.Ports
|
||||
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
|
||||
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined }));
|
||||
}
|
||||
return {
|
||||
Id: c.Id,
|
||||
Names: c.Names,
|
||||
Service: service,
|
||||
State: c.State,
|
||||
Status: c.Status,
|
||||
Labels: c.Labels,
|
||||
Ports
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type FleetOperationKind = 'update' | 'reapply_configuration';
|
||||
|
||||
export interface UpdateTracker {
|
||||
status: 'updating' | 'completed' | 'timeout' | 'failed';
|
||||
startedAt: number;
|
||||
@@ -11,6 +13,8 @@ export interface UpdateTracker {
|
||||
wasOffline: boolean;
|
||||
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
|
||||
resolvedAt?: number;
|
||||
/** Distinguishes version updates from compose reapply so poll heuristics stay correct. */
|
||||
operationKind: FleetOperationKind;
|
||||
}
|
||||
|
||||
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
|
||||
@@ -61,13 +65,15 @@ export class FleetUpdateTrackerService {
|
||||
return this.trackers.size;
|
||||
}
|
||||
|
||||
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal. */
|
||||
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal.
|
||||
* `operationKind` defaults to `'update'` so existing call sites stay unchanged. */
|
||||
public create(
|
||||
status: UpdateTracker['status'],
|
||||
previousVersion: string | null,
|
||||
previousProcessStart: number | null,
|
||||
error?: string,
|
||||
code?: string,
|
||||
operationKind: FleetOperationKind = 'update',
|
||||
): UpdateTracker {
|
||||
const now = Date.now();
|
||||
return {
|
||||
@@ -78,6 +84,7 @@ export class FleetUpdateTrackerService {
|
||||
wasOffline: false,
|
||||
error,
|
||||
code,
|
||||
operationKind,
|
||||
resolvedAt: status !== 'updating' ? now : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ImagePinKind } from '../helpers/selfUpdateCompose';
|
||||
import type { LocalRegistryAccess } from './hardenedEntitlementTypes';
|
||||
import { getAuthToken, httpRequest } from './registry-api';
|
||||
|
||||
export type ImageOperationKind = 'switch' | 'update' | 'community_update';
|
||||
export type ImageOperationKind = 'switch' | 'update' | 'community_update' | 'compose_reapply';
|
||||
export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed';
|
||||
type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart';
|
||||
|
||||
@@ -146,32 +146,12 @@ export class ImageOperationService {
|
||||
public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise<
|
||||
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
|
||||
> {
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
|
||||
const operation = this.newOperation(
|
||||
'community_update',
|
||||
resolved?.imageRef ?? null,
|
||||
options?.targetVersion ?? null,
|
||||
resolved?.filePath ?? null,
|
||||
selfUpdate.getComposeServiceName(),
|
||||
);
|
||||
if (!await this.tryClaim(operation)) {
|
||||
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
|
||||
}
|
||||
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
|
||||
// so a later claim can observe the persisted pending operation.
|
||||
this.claimed = false;
|
||||
return { ok: true };
|
||||
return this.claimComposeOperation('community_update', options?.targetVersion ?? null);
|
||||
}
|
||||
|
||||
public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> {
|
||||
const operation = await this.getCurrentOperation();
|
||||
if (!operation || operation.kind !== 'community_update') {
|
||||
return { ok: false, failureCode: 'update_failed' };
|
||||
}
|
||||
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) {
|
||||
return { ok: false, failureCode: 'update_failed' };
|
||||
}
|
||||
const operation = await this.getActiveClaimedOperation('community_update');
|
||||
if (!operation) return { ok: false, failureCode: 'update_failed' };
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
try {
|
||||
operation.state = 'pulling';
|
||||
@@ -207,6 +187,37 @@ export class ImageOperationService {
|
||||
return this.executeClaimedCommunityUpdate(options);
|
||||
}
|
||||
|
||||
public async claimComposeReapply(): Promise<
|
||||
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
|
||||
> {
|
||||
return this.claimComposeOperation('compose_reapply', null);
|
||||
}
|
||||
|
||||
public async executeClaimedComposeReapply(): Promise<{ ok: boolean; failureCode?: string }> {
|
||||
const operation = await this.getActiveClaimedOperation('compose_reapply');
|
||||
if (!operation) return { ok: false, failureCode: 'update_failed' };
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
try {
|
||||
// No pull/patch for reapply: jump straight to recreating.
|
||||
operation.state = 'recreating';
|
||||
await this.persist(operation);
|
||||
this.watchHelperExit(operation);
|
||||
await selfUpdate.triggerComposeReapply({
|
||||
successMarkerFile: this.successMarkerFile(operation),
|
||||
successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }),
|
||||
});
|
||||
if (selfUpdate.getLastError()) {
|
||||
await this.fail(operation, 'update_failed');
|
||||
return { ok: false, failureCode: 'update_failed' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error('[ImageOperation] Compose reapply failed:', error);
|
||||
await this.fail(operation, 'update_failed');
|
||||
return { ok: false, failureCode: 'update_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
public async getOperation(operationId: string): Promise<ImageOperation | null> {
|
||||
const filePath = this.operationFile(operationId);
|
||||
if (!filePath) return null;
|
||||
@@ -243,8 +254,9 @@ export class ImageOperationService {
|
||||
const markerPath = this.successMarkerFile(operation);
|
||||
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
|
||||
const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId);
|
||||
if (operation.kind === 'community_update') {
|
||||
// Community success is the marker alone; floating tags may not equal targetImageRef.
|
||||
if (operation.kind === 'community_update' || operation.kind === 'compose_reapply') {
|
||||
// Marker-only success: community updates may leave floating tags that do
|
||||
// not equal targetImageRef, and reapply never sets a target image at all.
|
||||
if (markerOk) {
|
||||
operation.state = 'succeeded';
|
||||
operation.resolvedAt = new Date().toISOString();
|
||||
@@ -284,6 +296,35 @@ export class ImageOperationService {
|
||||
});
|
||||
}
|
||||
|
||||
private async claimComposeOperation(
|
||||
kind: 'community_update' | 'compose_reapply',
|
||||
targetImageRef: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }> {
|
||||
const selfUpdate = SelfUpdateService.getInstance();
|
||||
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
|
||||
const operation = this.newOperation(
|
||||
kind,
|
||||
resolved?.imageRef ?? null,
|
||||
targetImageRef,
|
||||
resolved?.filePath ?? null,
|
||||
selfUpdate.getComposeServiceName(),
|
||||
);
|
||||
if (!await this.tryClaim(operation)) {
|
||||
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
|
||||
}
|
||||
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
|
||||
// so a later claim can observe the persisted pending operation.
|
||||
this.claimed = false;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async getActiveClaimedOperation(kind: ImageOperationKind): Promise<ImageOperation | null> {
|
||||
const operation = await this.getCurrentOperation();
|
||||
if (!operation || operation.kind !== kind) return null;
|
||||
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return null;
|
||||
return operation;
|
||||
}
|
||||
|
||||
private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -8,16 +8,43 @@ import { RegistryService } from './RegistryService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
||||
import { parseImageRef, selectLocalRepoDigest, compareLocalToRemoteTag } from './registry-api';
|
||||
import { parseImageRef, selectLocalRepoDigests } from './registry-api';
|
||||
import { detectImageUpdate, type PreviewImageCheckStatus } from './imageUpdateDetect';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { buildEffectiveServiceModel } from './effectiveServiceModel';
|
||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
|
||||
const BACKFILL_KEY = 'image_update_notifications_backfilled';
|
||||
|
||||
/** Post-update scanner reconciliation outcome for a single stack. */
|
||||
export type StackRecheckOutcome =
|
||||
| 'cleared'
|
||||
| 'still_present'
|
||||
| 'verification_incomplete'
|
||||
| 'verification_failed';
|
||||
|
||||
export interface StackRecheckResult {
|
||||
outcome: StackRecheckOutcome;
|
||||
/** Present when the update condition remains or could not be verified. */
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export const UPDATE_STILL_PRESENT_WARNING =
|
||||
'The update command completed, but Sencho still detects an available image update.';
|
||||
|
||||
export const UPDATE_VERIFICATION_INCOMPLETE_WARNING =
|
||||
'The update command completed, but Sencho could not fully verify whether an image update remains.';
|
||||
|
||||
export interface ImageCheckResult {
|
||||
hasUpdate: boolean;
|
||||
/** Same-tag registry digest drift; Compose pull can apply without pin change. */
|
||||
digestUpdate?: boolean;
|
||||
/** Higher semver tag exists; UI may show it but Compose auto-apply cannot pin it. */
|
||||
tagUpdate?: boolean;
|
||||
/** Detector authority; consumed by reduceServiceStatus / writeStackUpdateStatus. */
|
||||
checkStatus?: PreviewImageCheckStatus;
|
||||
error?: string;
|
||||
/**
|
||||
* The image is not registry-backed (locally built, or a bare digest ref
|
||||
@@ -28,6 +55,17 @@ export interface ImageCheckResult {
|
||||
notCheckable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize check authority for reduction. Test stubs / older callers may omit
|
||||
* checkStatus: error means failed, else ok.
|
||||
*/
|
||||
export function normalizeImageCheckStatus(r: ImageCheckResult): PreviewImageCheckStatus {
|
||||
if (r.notCheckable) return 'not_checkable';
|
||||
if (r.checkStatus) return r.checkStatus;
|
||||
if (r.error) return 'failed';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the scanner returned by GET /api/image-updates/status.
|
||||
* Units differ by field: `intervalMinutes` / `manualCooldownMinutes` are
|
||||
@@ -38,6 +76,8 @@ export interface ImageCheckResult {
|
||||
* `nextCheckAt` is meaningless while `checking` is true.
|
||||
* `mode` is the active scheduling mode; `cronExpression` is the 5-field
|
||||
* expression when mode is 'cron', null otherwise or when unconfigured.
|
||||
* `enabled` is whether background image-update detection is armed; always
|
||||
* present on current nodes, optional on the wire for older remotes.
|
||||
*/
|
||||
export interface ImageUpdateStatus {
|
||||
checking: boolean;
|
||||
@@ -49,6 +89,7 @@ export interface ImageUpdateStatus {
|
||||
mode: 'interval' | 'cron';
|
||||
cronExpression: string | null;
|
||||
sidebarIndicators: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Compose file helpers ────────────────────────────────────────────────────
|
||||
@@ -273,7 +314,8 @@ export function reduceServiceStatus(
|
||||
const checkableResults: ImageCheckResult[] = [];
|
||||
for (const ref of refs) {
|
||||
const result = imageUpdateMap.get(ref);
|
||||
if (!result || result.notCheckable) continue;
|
||||
if (!result) continue;
|
||||
if (normalizeImageCheckStatus(result) === 'not_checkable') continue;
|
||||
checkableResults.push(result);
|
||||
}
|
||||
|
||||
@@ -284,12 +326,14 @@ export function reduceServiceStatus(
|
||||
};
|
||||
}
|
||||
|
||||
const errored = checkableResults.filter((r) => r.error !== undefined);
|
||||
const statuses = checkableResults.map(normalizeImageCheckStatus);
|
||||
const failed = checkableResults.filter((_, i) => statuses[i] === 'failed');
|
||||
const partial = checkableResults.filter((_, i) => statuses[i] === 'partial');
|
||||
const confirmedUpdateThisRun = checkableResults.some(
|
||||
(r) => r.error === undefined && r.hasUpdate === true,
|
||||
(r, i) => statuses[i] === 'ok' && r.hasUpdate === true,
|
||||
);
|
||||
|
||||
if (errored.length === checkableResults.length) {
|
||||
if (failed.length === checkableResults.length) {
|
||||
const priorHasUpdate = prior?.hasUpdate ?? false;
|
||||
return {
|
||||
status: {
|
||||
@@ -298,14 +342,16 @@ export function reduceServiceStatus(
|
||||
...(dedupedRuntime.length > 0 ? { runtimeImages: dedupedRuntime } : {}),
|
||||
hasUpdate: priorHasUpdate,
|
||||
checkStatus: 'failed',
|
||||
lastError: errored[0].error ?? 'Update check failed',
|
||||
lastError: failed[0].error ?? 'Update check failed',
|
||||
},
|
||||
confirmedUpdateThisRun: false,
|
||||
};
|
||||
}
|
||||
|
||||
const checkStatus: StackServiceStatus['checkStatus'] = errored.length > 0 ? 'partial' : 'ok';
|
||||
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
|
||||
const checkStatus: StackServiceStatus['checkStatus'] =
|
||||
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
|
||||
const uncertain = [...partial, ...failed];
|
||||
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
|
||||
const hasUpdate = checkStatus === 'partial'
|
||||
? (confirmedUpdateThisRun || (prior?.hasUpdate ?? false))
|
||||
: confirmedUpdateThisRun;
|
||||
@@ -368,6 +414,7 @@ export class ImageUpdateService {
|
||||
private static readonly INTERVAL_SETTING_KEY = 'image_update_check_interval_minutes';
|
||||
private static readonly MODE_SETTING_KEY = 'image_update_check_mode';
|
||||
private static readonly CRON_SETTING_KEY = 'image_update_check_cron';
|
||||
private static readonly ENABLED_SETTING_KEY = 'image_update_checks_enabled';
|
||||
private static readonly JITTER_FRACTION = 0.1; // ±10% so a fleet does not poll in lockstep
|
||||
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
|
||||
|
||||
@@ -410,8 +457,15 @@ export class ImageUpdateService {
|
||||
|
||||
public start() {
|
||||
if (this.timer) return;
|
||||
this.polling = true;
|
||||
this.configureFromSettings();
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
// Detection opted out: stay stopped across restarts so a boot does
|
||||
// not re-arm registry polling until the setting is turned back on.
|
||||
this.polling = false;
|
||||
this.nextCheckAt = null;
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
// Interval mode keeps the 2-minute post-boot delay before the first check.
|
||||
// Cron mode honors its schedule: arm at the next cron fire time so a restart
|
||||
// never triggers an out-of-cadence check (e.g. a weekly cron must not run on
|
||||
@@ -434,7 +488,8 @@ export class ImageUpdateService {
|
||||
* cadence without restarting Sencho. Safe to call repeatedly: it always
|
||||
* clears the existing timer first and only arms a new one while polling, so
|
||||
* it never stacks timers and is a no-op (beyond reconfiguring intervalMs)
|
||||
* when the service is stopped or was never started.
|
||||
* when the service is stopped or was never started. When checks are
|
||||
* disabled, clears nextCheckAt and does not arm.
|
||||
*/
|
||||
public restartPolling(): void {
|
||||
this.scheduleGeneration++;
|
||||
@@ -443,13 +498,65 @@ export class ImageUpdateService {
|
||||
this.timer = null;
|
||||
}
|
||||
this.configureFromSettings();
|
||||
if (this.polling) {
|
||||
if (this.polling && ImageUpdateService.isChecksEnabled()) {
|
||||
this.armNext(this.nextDelayMs());
|
||||
} else {
|
||||
this.nextCheckAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether background image-update detection is enabled. Missing or blank
|
||||
* keys default to enabled so upgrades and pre-seed races keep polling.
|
||||
*/
|
||||
public static isChecksEnabled(): boolean {
|
||||
try {
|
||||
const raw = DatabaseService.getInstance().getGlobalSettings()[ImageUpdateService.ENABLED_SETTING_KEY];
|
||||
if (raw == null || String(raw).trim() === '') return true;
|
||||
return raw === '1';
|
||||
} catch (e) {
|
||||
console.warn('[ImageUpdateService] Could not read checks-enabled setting; treating as enabled:', getErrorMessage(e, String(e)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the checks-enabled setting and apply the live transition: stop
|
||||
* + clear findings when turning off; start (or re-arm) when turning on.
|
||||
* Safe under repeated toggles (scheduleGeneration bump via stop/start).
|
||||
*/
|
||||
public applyChecksEnabled(enabled: boolean): ImageUpdateStatus {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting(ImageUpdateService.ENABLED_SETTING_KEY, enabled ? '1' : '0');
|
||||
|
||||
if (!enabled) {
|
||||
this.stop();
|
||||
// Scanner only writes rows for local nodes. Use the local default
|
||||
// node ID, not req.nodeId (which may be a remote active node).
|
||||
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
db.clearAllStackUpdateStatus(localNodeId);
|
||||
invalidateFleetUpdateCache();
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId: localNodeId,
|
||||
action: 'checks-disabled',
|
||||
ts: Date.now(),
|
||||
});
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
// Re-enable: arm a fresh schedule. start() is a no-op if a timer already
|
||||
// exists; when we were fully stopped, start() arms. When somehow still
|
||||
// marked polling without a timer, restartPolling re-arms.
|
||||
if (!this.timer) {
|
||||
this.start();
|
||||
} else {
|
||||
this.restartPolling();
|
||||
}
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads image_update_check_interval_minutes into intervalMs, clamped to
|
||||
* [15, 1440], falling back to the 2-hour default on a missing, blank,
|
||||
@@ -558,11 +665,16 @@ export class ImageUpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a check immediately, unless one is already running or the
|
||||
* manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
|
||||
* Returns false if rate-limited, true if a check was started.
|
||||
* Triggers a check immediately, unless detection is disabled, one is already
|
||||
* running, or the manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
|
||||
* Returns false if rate-limited or disabled, true if a check was started.
|
||||
* Callers that need to distinguish disabled from rate-limited must check
|
||||
* isChecksEnabled() first.
|
||||
*/
|
||||
public triggerManualRefresh(): boolean {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
|
||||
return false;
|
||||
@@ -582,6 +694,7 @@ export class ImageUpdateService {
|
||||
}
|
||||
|
||||
public getStatus(): ImageUpdateStatus {
|
||||
const enabled = ImageUpdateService.isChecksEnabled();
|
||||
let sidebarIndicators = false;
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
@@ -590,21 +703,28 @@ export class ImageUpdateService {
|
||||
console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e);
|
||||
}
|
||||
return {
|
||||
checking: this.isRunning,
|
||||
checking: enabled ? this.isRunning : false,
|
||||
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
|
||||
lastCheckedAt: this.lastCheckedAt,
|
||||
nextCheckAt: this.nextCheckAt,
|
||||
nextCheckAt: enabled ? this.nextCheckAt : null,
|
||||
manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes,
|
||||
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
||||
mode: this.mode,
|
||||
cronExpression: this.cronExpression,
|
||||
sidebarIndicators,
|
||||
enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Core check ──────────────────────────────────────────────────────────
|
||||
|
||||
private async check() {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
if (isDebugEnabled()) {
|
||||
console.log('[ImageUpdateService:debug] Checks disabled; skipping scan.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The finally block is the sole owner of isRunning, so a scan that
|
||||
// overruns can never have its lock released out from under it. A
|
||||
// previous fixed timer cleared the lock after CHECK_TIMEOUT_MS, which
|
||||
@@ -770,7 +890,11 @@ export class ImageUpdateService {
|
||||
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
// getErrorMessage (not raw String(e)) because this value can surface
|
||||
// verbatim in the sidebar tooltip / readiness advisory as lastError.
|
||||
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
|
||||
imageUpdateMap.set(imageRef, {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: getErrorMessage(e, 'Update check failed'),
|
||||
});
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
}
|
||||
@@ -857,19 +981,28 @@ export class ImageUpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check a single stack after a service-scoped update or restore. On a
|
||||
* render failure the prior row is left untouched and a warning is returned.
|
||||
* Re-check a single stack after a service-scoped update or restore, or
|
||||
* after a manual full-stack update. On a render failure the prior row is
|
||||
* left untouched and a verification_failed result is returned.
|
||||
*/
|
||||
public async recheckStack(nodeId: number, stackName: string): Promise<{ warning: string | null }> {
|
||||
public async recheckStack(nodeId: number, stackName: string): Promise<StackRecheckResult> {
|
||||
// While detection is off, skip registry probes and do not write
|
||||
// stack_update_status (avoids stale findings after re-enable).
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
return { outcome: 'cleared', warning: null };
|
||||
}
|
||||
const generation = this.reserveStackWriteGeneration(nodeId, stackName);
|
||||
const db = DatabaseService.getInstance();
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const model = await buildEffectiveServiceModel(nodeId, stackName);
|
||||
if (!model.renderable) {
|
||||
return { warning: model.error };
|
||||
return {
|
||||
outcome: 'verification_failed',
|
||||
warning: model.error || UPDATE_VERIFICATION_INCOMPLETE_WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
let containers: Array<{ Image?: string; Labels?: Record<string, string> }> = [];
|
||||
let containers: Array<{ Image?: string; Labels?: Record<string, string> }>;
|
||||
try {
|
||||
containers = await withTimeout(docker.getAllContainers(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getAllContainers');
|
||||
} catch (e) {
|
||||
@@ -878,6 +1011,12 @@ export class ImageUpdateService {
|
||||
sanitizeForLog(stackName),
|
||||
sanitizeForLog(getErrorMessage(e, 'unknown')),
|
||||
);
|
||||
// Do not clear or upsert from declared-image-only checks: runtime
|
||||
// digests were never observed, so "cleared" would be a false negative.
|
||||
return {
|
||||
outcome: 'verification_incomplete',
|
||||
warning: UPDATE_VERIFICATION_INCOMPLETE_WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
const refs = new Set<string>();
|
||||
@@ -892,7 +1031,11 @@ export class ImageUpdateService {
|
||||
try {
|
||||
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
|
||||
} catch (e) {
|
||||
imageUpdateMap.set(imageRef, { hasUpdate: false, error: getErrorMessage(e, 'Update check failed') });
|
||||
imageUpdateMap.set(imageRef, {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: getErrorMessage(e, 'Update check failed'),
|
||||
});
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
}
|
||||
@@ -911,15 +1054,34 @@ export class ImageUpdateService {
|
||||
const lastError = stackStatusLastError(services);
|
||||
const now = Date.now();
|
||||
|
||||
await this.withStackWriteLock(nodeId, stackName, generation, async (gen) => {
|
||||
const committed = await this.withStackWriteLock(nodeId, stackName, generation, async (gen) => {
|
||||
if (checkStatus === 'failed') {
|
||||
db.recordStackCheckFailure(nodeId, stackName, lastError ?? 'Update check failed', now, services, gen);
|
||||
} else {
|
||||
db.upsertStackUpdateStatus(nodeId, stackName, hasUpdate, now, checkStatus, lastError, services, gen);
|
||||
}
|
||||
});
|
||||
// A newer scanner reservation dropped this write; do not report cleared.
|
||||
if (!committed) {
|
||||
return {
|
||||
outcome: 'verification_incomplete',
|
||||
warning: UPDATE_VERIFICATION_INCOMPLETE_WARNING,
|
||||
};
|
||||
}
|
||||
|
||||
return { warning: null };
|
||||
if (checkStatus === 'partial' || checkStatus === 'failed') {
|
||||
return {
|
||||
outcome: 'verification_incomplete',
|
||||
warning: UPDATE_VERIFICATION_INCOMPLETE_WARNING,
|
||||
};
|
||||
}
|
||||
if (hasUpdate) {
|
||||
return {
|
||||
outcome: 'still_present',
|
||||
warning: UPDATE_STILL_PRESENT_WARNING,
|
||||
};
|
||||
}
|
||||
return { outcome: 'cleared', warning: null };
|
||||
}
|
||||
|
||||
private stackWriteKey(nodeId: number, stackName: string): string {
|
||||
@@ -963,6 +1125,70 @@ export class ImageUpdateService {
|
||||
return committed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current per-stack write-generation high-water mark (0 if never reserved).
|
||||
* Snapshot before a read-only update-preview so commitPreviewClear can
|
||||
* compare against writes that reserved or committed after observation.
|
||||
*/
|
||||
public peekStackWriteGeneration(nodeId: number, stackName: string): number {
|
||||
return this.stackWriteState.get(this.stackWriteKey(nodeId, stackName))?.generation ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted scanner update state after an authoritative-negative
|
||||
* update preview. `observedMemoryGeneration` and `observedRowGeneration`
|
||||
* are snapshotted before the preview.
|
||||
*
|
||||
* Ordering:
|
||||
* - If memory generation advanced after observation, abort (stale).
|
||||
* - If memory generation still equals the observation watermark, advance
|
||||
* (tombstone) so an equal-generation writer reserved before observation
|
||||
* cannot commit after the clear (SF-4).
|
||||
* - If the persisted row generation advanced after observation, keep the row.
|
||||
* - Otherwise delete partial, failed, and confirmed ok+true rows.
|
||||
*
|
||||
* Returns cleared | stale | absent.
|
||||
*/
|
||||
public async commitPreviewClear(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
observedMemoryGeneration: number,
|
||||
observedRowGeneration: number,
|
||||
): Promise<'cleared' | 'stale' | 'absent'> {
|
||||
const key = this.stackWriteKey(nodeId, stackName);
|
||||
let state = this.stackWriteState.get(key);
|
||||
if (!state) {
|
||||
state = { chain: Promise.resolve(), generation: observedMemoryGeneration };
|
||||
this.stackWriteState.set(key, state);
|
||||
}
|
||||
|
||||
// A reservation after observation already owns a higher generation.
|
||||
if (state.generation > observedMemoryGeneration) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
// Tombstone the equal watermark so pre-observation writers reserved at
|
||||
// this generation become stale when they later try to commit.
|
||||
if (state.generation === observedMemoryGeneration) {
|
||||
state.generation += 1;
|
||||
}
|
||||
const clearGeneration = state.generation;
|
||||
|
||||
let deleted = 0;
|
||||
const committed = await this.withStackWriteLock(nodeId, stackName, clearGeneration, () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const detail = db.getStackUpdateDetail(nodeId)[stackName];
|
||||
if (!detail) return;
|
||||
// Compare DB-embedded generations only (same dimension as the
|
||||
// pre-preview snapshot). Memory peek resets on restart; SQLite does not.
|
||||
const rowGeneration = db.getStackUpdateWriteGeneration(nodeId, stackName);
|
||||
if (rowGeneration > observedRowGeneration) return;
|
||||
deleted = db.clearStackUpdateStatus(nodeId, stackName);
|
||||
});
|
||||
if (!committed) return 'stale';
|
||||
return deleted > 0 ? 'cleared' : 'absent';
|
||||
}
|
||||
|
||||
private runtimeImagesByService(
|
||||
stackName: string,
|
||||
containers: Array<{ Image?: string; Labels?: Record<string, string> }>,
|
||||
@@ -1028,14 +1254,16 @@ export class ImageUpdateService {
|
||||
|
||||
const checkable = Array.from(images)
|
||||
.map((img) => imageUpdateMap.get(img))
|
||||
.filter((r): r is ImageCheckResult => !!r && !r.notCheckable);
|
||||
const errored = checkable.filter((r) => r.error !== undefined);
|
||||
const confirmedHasUpdate = checkable.some((r) => r.error === undefined && r.hasUpdate === true);
|
||||
.filter((r): r is ImageCheckResult => !!r && normalizeImageCheckStatus(r) !== 'not_checkable');
|
||||
const statuses = checkable.map(normalizeImageCheckStatus);
|
||||
const failed = checkable.filter((_, i) => statuses[i] === 'failed');
|
||||
const partial = checkable.filter((_, i) => statuses[i] === 'partial');
|
||||
const confirmedHasUpdate = checkable.some((r, i) => statuses[i] === 'ok' && r.hasUpdate === true);
|
||||
|
||||
if (checkable.length > 0 && errored.length === checkable.length) {
|
||||
if (checkable.length > 0 && failed.length === checkable.length) {
|
||||
const committed = await this.withStackWriteLock(nodeId, stackName, generation, async () => {
|
||||
db.recordStackCheckFailure(
|
||||
nodeId, stackName, errored[0].error ?? 'Update check failed', checkedAt,
|
||||
nodeId, stackName, failed[0].error ?? 'Update check failed', checkedAt,
|
||||
);
|
||||
});
|
||||
return {
|
||||
@@ -1045,8 +1273,10 @@ export class ImageUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
const checkStatus: StackCheckStatus = errored.length > 0 ? 'partial' : 'ok';
|
||||
const lastError = errored.length > 0 ? (errored[0].error ?? null) : null;
|
||||
const checkStatus: StackCheckStatus =
|
||||
failed.length > 0 || partial.length > 0 ? 'partial' : 'ok';
|
||||
const uncertain = [...partial, ...failed];
|
||||
const lastError = uncertain.length > 0 ? (uncertain[0].error ?? null) : null;
|
||||
const hasUpdate = checkStatus === 'partial'
|
||||
? (confirmedHasUpdate || previousState[stackName] === true)
|
||||
: confirmedHasUpdate;
|
||||
@@ -1065,20 +1295,21 @@ export class ImageUpdateService {
|
||||
public async checkImage(docker: DockerController, imageRef: string): Promise<ImageCheckResult> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
// A bare digest ref (sha256:...) has no tag to track upstream; not applicable.
|
||||
if (!parsed) return { hasUpdate: false, notCheckable: true };
|
||||
if (!parsed) {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[ImageUpdateService] Checking ${imageRef}: registry=${parsed.registry} repo=${parsed.repo} tag=${parsed.tag}`);
|
||||
}
|
||||
|
||||
// Look up stored credentials for this registry
|
||||
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
|
||||
}
|
||||
|
||||
// Get local digest and platform from RepoDigests / Os+Architecture
|
||||
let localDigest: string | null;
|
||||
// Get local digests and platform from RepoDigests / Os+Architecture
|
||||
let localDigests: string[];
|
||||
let platform: { os: string; architecture: string };
|
||||
try {
|
||||
const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect');
|
||||
@@ -1086,28 +1317,58 @@ export class ImageUpdateService {
|
||||
|
||||
// No RepoDigests at all: locally built / not registry-backed, so update
|
||||
// status does not apply.
|
||||
if (repoDigests.length === 0) return { hasUpdate: false, notCheckable: true };
|
||||
if (repoDigests.length === 0) {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
localDigest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
localDigests = selectLocalRepoDigests(repoDigests, parsed);
|
||||
platform = { os: inspect.Os, architecture: inspect.Architecture };
|
||||
} catch {
|
||||
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
|
||||
return {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: `Failed to inspect local image "${imageRef}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// RepoDigests were present but none resolved a usable digest: genuinely
|
||||
// ambiguous, so surface it rather than silently call the image up to date.
|
||||
if (!localDigest) {
|
||||
return { hasUpdate: false, error: `Could not resolve a local registry digest for "${imageRef}"` };
|
||||
if (localDigests.length === 0) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
checkStatus: 'failed',
|
||||
error: `Could not resolve a local registry digest for "${imageRef}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const comparison = await compareLocalToRemoteTag(localDigest, parsed.registry, parsed.repo, parsed.tag, platform, credentials);
|
||||
if (comparison.kind === 'error') {
|
||||
return { hasUpdate: false, error: comparison.reason };
|
||||
const detection = await detectImageUpdate({
|
||||
localDigests,
|
||||
platform,
|
||||
registry: parsed.registry,
|
||||
repo: parsed.repo,
|
||||
tag: parsed.tag,
|
||||
credentials,
|
||||
});
|
||||
|
||||
const digestLabel = localDigests[0] ? `${localDigests[0].slice(0, 27)}...` : 'none';
|
||||
const nextSuffix = detection.nextTag ? ` next=${detection.nextTag}` : '';
|
||||
console.log(
|
||||
`[ImageUpdateService] ${imageRef}: local=${digestLabel} update=${detection.hasUpdate}`
|
||||
+ ` digest=${detection.digestUpdate} tag=${detection.tagUpdate}`
|
||||
+ ` status=${detection.checkStatus}${nextSuffix}`,
|
||||
);
|
||||
|
||||
if (detection.checkStatus === 'not_checkable') {
|
||||
return { hasUpdate: false, checkStatus: 'not_checkable', notCheckable: true };
|
||||
}
|
||||
|
||||
const hasUpdate = comparison.kind === 'update';
|
||||
console.log(`[ImageUpdateService] ${imageRef}: local=${localDigest.slice(0, 27)}... update=${hasUpdate}`);
|
||||
return { hasUpdate };
|
||||
return {
|
||||
hasUpdate: detection.hasUpdate,
|
||||
digestUpdate: detection.digestUpdate,
|
||||
tagUpdate: detection.tagUpdate,
|
||||
checkStatus: detection.checkStatus,
|
||||
...(detection.reason ? { error: detection.reason } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,15 @@ import { StackOpLockService, stackOpSkipMessage as skipMessage } from './StackOp
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { HealthGateService } from './HealthGateService';
|
||||
import { ServiceUpdateRecoveryService } from './ServiceUpdateRecoveryService';
|
||||
import { ImageUpdateService } from './ImageUpdateService';
|
||||
import type { ImageCheckResult } from './ImageUpdateService';
|
||||
import {
|
||||
createAutoUpdateDigestGateState,
|
||||
messageWhenDigestApplyBlockedByCheckErrors,
|
||||
messageWhenNoDigestUpdate,
|
||||
recordAutoUpdateImageCheck,
|
||||
} from '../helpers/autoUpdateDigestGate';
|
||||
import { ImageUpdateService, UPDATE_VERIFICATION_INCOMPLETE_WARNING } from './ImageUpdateService';
|
||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { formatNoTargetError } from '../utils/remoteTarget';
|
||||
@@ -732,6 +739,10 @@ export class SchedulerService {
|
||||
}
|
||||
|
||||
private async executeUpdate(task: ScheduledTask): Promise<string> {
|
||||
if (task.selector_type === 'stack-label') {
|
||||
return this.executeUpdateByStackLabel(task);
|
||||
}
|
||||
|
||||
if (task.node_id == null) {
|
||||
throw new Error('Auto-update requires node_id');
|
||||
}
|
||||
@@ -766,14 +777,13 @@ export class SchedulerService {
|
||||
console.log(`[SchedulerService] executeUpdate: ${stackNames.length} stack(s) to check, fleet=${isFleet}, wildcard=${isWildcard}`);
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const docker = DockerController.getInstance(task.node_id);
|
||||
const imageUpdateService = ImageUpdateService.getInstance();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const stackName of stackNames) {
|
||||
try {
|
||||
const output = await this.executeUpdateForStack(stackName, task.node_id, docker, imageUpdateService, db, isFleet || isWildcard);
|
||||
const output = await this.executeUpdateForStack(stackName, task.node_id, docker, imageUpdateService, isFleet || isWildcard);
|
||||
results.push(output);
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
@@ -785,6 +795,140 @@ export class SchedulerService {
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve live stack-label membership (fleet-wide or one node) and run the
|
||||
* existing per-stack auto-update path. Remotes receive an explicit stack
|
||||
* list; they do not evaluate the selector themselves.
|
||||
*/
|
||||
private async executeUpdateByStackLabel(task: ScheduledTask): Promise<string> {
|
||||
const labelName = (task.selector_value ?? '').trim();
|
||||
if (!labelName) {
|
||||
throw new Error('Label-targeted auto-update requires selector_value');
|
||||
}
|
||||
|
||||
const { collectFleetLabelSummaries } = await import('../helpers/fleetLabelSummary');
|
||||
let summaries = await collectFleetLabelSummaries();
|
||||
if (task.node_id != null) {
|
||||
summaries = summaries.filter(s => s.nodeId === task.node_id);
|
||||
if (summaries.length === 0) {
|
||||
throw new Error(`Target node (id=${task.node_id}) no longer exists`);
|
||||
}
|
||||
}
|
||||
|
||||
type NodePlan = {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reachable: boolean;
|
||||
stacks: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const plans: NodePlan[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const summary of summaries) {
|
||||
if (!summary.reachable) {
|
||||
plans.push({
|
||||
nodeId: summary.nodeId,
|
||||
nodeName: summary.nodeName,
|
||||
reachable: false,
|
||||
stacks: [],
|
||||
error: summary.error ?? 'unreachable',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const match = summary.labels.find(l => l.name === labelName);
|
||||
const stacks: string[] = [];
|
||||
if (match) {
|
||||
for (const stackName of match.stackNames) {
|
||||
const key = `${summary.nodeId}\0${stackName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
stacks.push(stackName);
|
||||
}
|
||||
}
|
||||
plans.push({
|
||||
nodeId: summary.nodeId,
|
||||
nodeName: summary.nodeName,
|
||||
reachable: true,
|
||||
stacks,
|
||||
});
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Selector: stack-label="${labelName}" · scope=${task.node_id == null ? 'entire fleet' : `node ${task.node_id}`}`,
|
||||
];
|
||||
for (const plan of plans) {
|
||||
if (!plan.reachable) {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): unreachable (${plan.error})`);
|
||||
} else if (plan.stacks.length === 0) {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): no stacks with label "${labelName}"`);
|
||||
} else {
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): ${plan.stacks.length} stack(s) → ${plan.stacks.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const matchedStacks = plans.reduce((n, p) => n + p.stacks.length, 0);
|
||||
const unreachableCount = plans.filter(p => !p.reachable).length;
|
||||
if (matchedStacks === 0 && unreachableCount === 0) {
|
||||
lines.push(`No stacks currently match label "${labelName}"; skipped.`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
let materialFailure = unreachableCount > 0;
|
||||
const work = plans.filter(p => p.reachable && p.stacks.length > 0);
|
||||
const NODE_CONCURRENCY = 3;
|
||||
// Label-targeted runs fail closed on material stack failures or
|
||||
// unreachable scoped nodes (unlike plain node fleet update, which
|
||||
// historically returns failure lines as a successful run output).
|
||||
|
||||
const looksLikeStackFailure = (text: string): boolean =>
|
||||
/^Stack ".+" failed:/m.test(text);
|
||||
|
||||
const runNode = async (plan: NodePlan): Promise<void> => {
|
||||
const node = NodeRegistry.getInstance().getNode(plan.nodeId);
|
||||
try {
|
||||
if (node?.type === 'remote') {
|
||||
const remoteOut = await this.executeUpdateRemoteTargets(plan.nodeId, plan.stacks);
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${remoteOut}`);
|
||||
if (looksLikeStackFailure(remoteOut)) materialFailure = true;
|
||||
} else {
|
||||
const docker = DockerController.getInstance(plan.nodeId);
|
||||
const imageUpdateService = ImageUpdateService.getInstance();
|
||||
const stackLines: string[] = [];
|
||||
for (const stackName of plan.stacks) {
|
||||
try {
|
||||
const out = await this.executeUpdateForStack(
|
||||
stackName, plan.nodeId, docker, imageUpdateService, true,
|
||||
);
|
||||
stackLines.push(out);
|
||||
} catch (e) {
|
||||
materialFailure = true;
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
stackLines.push(`Stack "${stackName}" failed: ${msg}`);
|
||||
console.error(`[SchedulerService] Label auto-update failed for stack "${stackName}" on node ${plan.nodeId}:`, e);
|
||||
}
|
||||
}
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}) results:\n${stackLines.join('\n')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
materialFailure = true;
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
lines.push(`Node "${plan.nodeName}" (id=${plan.nodeId}): failed (${msg})`);
|
||||
console.error(`[SchedulerService] Label auto-update failed for node ${plan.nodeId}:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < work.length; i += NODE_CONCURRENCY) {
|
||||
const batch = work.slice(i, i + NODE_CONCURRENCY);
|
||||
await Promise.all(batch.map(runNode));
|
||||
}
|
||||
|
||||
if (materialFailure) {
|
||||
throw new Error(lines.join('\n'));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy auto-update execution to a remote Sencho instance.
|
||||
* The remote node runs the image checks and compose update locally.
|
||||
@@ -823,6 +967,55 @@ export class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Proxy auto-update for an explicit stack list on a remote node (label selector). */
|
||||
private async executeUpdateRemoteTargets(nodeId: number, targets: string[]): Promise<string> {
|
||||
const proxyTarget = this.requireRemoteProxyTarget(nodeId);
|
||||
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
|
||||
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[SchedulerService] executeUpdateRemoteTargets: node=${nodeId} count=${targets.length}`);
|
||||
}
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/auto-update/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${proxyTarget.apiToken}`,
|
||||
[PROXY_TIER_HEADER]: proxyHeaders.tier,
|
||||
},
|
||||
body: JSON.stringify({ targets }),
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
});
|
||||
|
||||
// Older remotes only accept { target }. Fall back to one call per
|
||||
// stack so mixed-version fleets still complete the label schedule.
|
||||
if (response.status === 400) {
|
||||
const detail = await this.remoteResponseDetail(response);
|
||||
if (/target/i.test(detail)) {
|
||||
const parts: string[] = [];
|
||||
for (const stackName of targets) {
|
||||
parts.push(await this.executeUpdateRemote(nodeId, stackName));
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
throw new Error(this.remoteProxyFailureMessage(nodeId, detail));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(this.remoteProxyFailureMessage(nodeId, await this.remoteResponseDetail(response)));
|
||||
}
|
||||
|
||||
const body = await response.json() as { result?: string };
|
||||
if (isDebugEnabled()) {
|
||||
console.log(`[SchedulerService] executeUpdateRemoteTargets: completed in ${Date.now() - startTime}ms`);
|
||||
}
|
||||
return body.result || 'Remote auto-update completed (no details returned).';
|
||||
} catch (err) {
|
||||
this.rethrowRemoteProxyError(nodeId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a stack lifecycle action to a remote Sencho instance. ComposeService,
|
||||
* DockerController, and FileSystemService are local-only, so for a remote node
|
||||
@@ -1030,9 +1223,12 @@ export class SchedulerService {
|
||||
nodeId: number,
|
||||
docker: DockerController,
|
||||
imageUpdateService: ImageUpdateService,
|
||||
db: DatabaseService,
|
||||
isWildcard = false
|
||||
): Promise<string> {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
console.log(`[SchedulerService] Stack "${stackName}": image update detection is disabled; skipped.`);
|
||||
return `Stack "${stackName}": image update detection is disabled; skipped.`;
|
||||
}
|
||||
const containers = await docker.getContainersByStack(stackName);
|
||||
if (!containers || containers.length === 0) {
|
||||
if (!isWildcard) {
|
||||
@@ -1056,35 +1252,26 @@ export class SchedulerService {
|
||||
console.log(`[SchedulerService] Stack "${stackName}": checking ${imageRefs.length} image(s): ${imageRefs.join(', ')}`);
|
||||
}
|
||||
|
||||
let hasUpdate = false;
|
||||
const updatedImages: string[] = [];
|
||||
const checkErrors: string[] = [];
|
||||
const gate = createAutoUpdateDigestGateState();
|
||||
|
||||
for (const imageRef of imageRefs) {
|
||||
try {
|
||||
const result: ImageCheckResult = await imageUpdateService.checkImage(docker, imageRef);
|
||||
if (result.error) {
|
||||
checkErrors.push(result.error);
|
||||
} else if (result.hasUpdate) {
|
||||
hasUpdate = true;
|
||||
updatedImages.push(imageRef);
|
||||
}
|
||||
const result = await imageUpdateService.checkImage(docker, imageRef);
|
||||
recordAutoUpdateImageCheck(gate, imageRef, result);
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(msg);
|
||||
gate.checkErrors.push(msg);
|
||||
console.warn(`[SchedulerService] Failed to check image ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUpdate) {
|
||||
if (checkErrors.length > 0 && checkErrors.length === imageRefs.length) {
|
||||
return `Stack "${stackName}": WARNING - all image checks failed (${checkErrors.join('; ')}). Unable to determine update status.`;
|
||||
}
|
||||
if (checkErrors.length > 0) {
|
||||
return `Stack "${stackName}": all reachable images up to date (${checkErrors.length} check(s) failed).`;
|
||||
}
|
||||
return `Stack "${stackName}": all images up to date.`;
|
||||
if (!gate.hasDigestUpdate) {
|
||||
return messageWhenNoDigestUpdate(stackName, gate, imageRefs.length);
|
||||
}
|
||||
const checkErrorBlock = messageWhenDigestApplyBlockedByCheckErrors(stackName, gate);
|
||||
if (checkErrorBlock) return checkErrorBlock;
|
||||
|
||||
const { updatedImages } = gate;
|
||||
|
||||
await this.enforceSchedulerPolicyGate(
|
||||
stackName,
|
||||
@@ -1103,7 +1290,9 @@ export class SchedulerService {
|
||||
),
|
||||
);
|
||||
if (!lock.ran) return skipMessage(stackName, lock.existing.action);
|
||||
db.clearStackUpdateStatus(nodeId, stackName);
|
||||
|
||||
// Health observation starts immediately after Compose; registry recheck is
|
||||
// isolated so a verification failure cannot turn Compose success into a failure.
|
||||
const healthGateId = HealthGateService.getInstance().beginStack(nodeId, stackName, 'update', 'system:scheduler');
|
||||
const orchResult = lock.result;
|
||||
const recoveryId = orchResult && orchResult.kind === 'stack_compose_done' ? orchResult.recoveryId : null;
|
||||
@@ -1112,6 +1301,30 @@ export class SchedulerService {
|
||||
StackUpdateRecoveryService.getInstance().linkGateOrRetain(recoveryId, healthGateId);
|
||||
}
|
||||
|
||||
// Recheck persists digest-cleared / tag-advisory state. Do not blind-clear.
|
||||
let recheckWarning: string | undefined;
|
||||
try {
|
||||
const recheck = await imageUpdateService.recheckStack(nodeId, stackName);
|
||||
if (recheck.warning) recheckWarning = recheck.warning;
|
||||
} catch (recheckErr) {
|
||||
console.warn(
|
||||
`[SchedulerService] Post-update recheck failed for ${sanitizeForLog(stackName)}:`,
|
||||
sanitizeForLog(getErrorMessage(recheckErr, 'unknown')),
|
||||
);
|
||||
recheckWarning = UPDATE_VERIFICATION_INCOMPLETE_WARNING;
|
||||
}
|
||||
|
||||
invalidateFleetUpdateCache();
|
||||
invalidateNodeCaches(nodeId);
|
||||
NotificationService.getInstance().broadcastEvent({
|
||||
type: 'state-invalidate',
|
||||
scope: 'image-updates',
|
||||
nodeId,
|
||||
stackName,
|
||||
action: 'stack-updated',
|
||||
ts: Date.now(),
|
||||
});
|
||||
|
||||
this.safeDispatch(
|
||||
'info',
|
||||
'image_update_applied',
|
||||
@@ -1119,7 +1332,8 @@ export class SchedulerService {
|
||||
stackName
|
||||
);
|
||||
|
||||
return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
|
||||
const base = `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
|
||||
return recheckWarning ? `${base} ${recheckWarning}` : base;
|
||||
}
|
||||
|
||||
private async executeScan(task: ScheduledTask): Promise<{ output: string; failed: number }> {
|
||||
|
||||
@@ -205,6 +205,27 @@ export function buildSelfUpdateRunArgs(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argv for a throwaway helper that runs `docker compose … config`
|
||||
* against the host compose project. Reuses the recreate helper's mount layout
|
||||
* (socket + working dir + host binds) without mounting /app/data, since
|
||||
* validation is read-only. Pure and exported for unit testing.
|
||||
*/
|
||||
export function buildComposeConfigValidateArgs(
|
||||
ctx: Pick<ComposeContext, 'workingDir' | 'imageName' | 'hostBindMounts'> & { configFiles: string },
|
||||
): string[] {
|
||||
const { workingDir, imageName, hostBindMounts, configFiles } = ctx;
|
||||
const fFlags = configFiles.split(',').flatMap(f => {
|
||||
const trimmed = f.trim();
|
||||
return trimmed ? ['-f', trimmed] : [];
|
||||
});
|
||||
const composeCmd = ['docker compose', ...fFlags.map(shQuote), 'config'].join(' ');
|
||||
return buildSelfUpdateRunArgs(
|
||||
{ workingDir, imageName, dataDirHost: null, hostBindMounts },
|
||||
composeCmd,
|
||||
);
|
||||
}
|
||||
|
||||
class SelfUpdateService {
|
||||
private static instance: SelfUpdateService;
|
||||
private canSelfUpdate = false;
|
||||
@@ -527,6 +548,46 @@ class SelfUpdateService {
|
||||
this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate the Sencho service from the exact current on-disk Compose project
|
||||
* without pulling or rewriting the image reference. Used by Fleet "Reapply
|
||||
* configuration". Validates the authored compose via a throwaway helper
|
||||
* before the last-breath recreate so invalid config fails before shutdown.
|
||||
*/
|
||||
async triggerComposeReapply(options?: {
|
||||
successMarkerFile?: string;
|
||||
successMarkerContent?: string;
|
||||
}): Promise<void> {
|
||||
if (!this.composeContext) return;
|
||||
const env = this.buildEnv();
|
||||
this.lastUpdateError = null;
|
||||
this.pendingHelperExitError = undefined;
|
||||
|
||||
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
|
||||
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
|
||||
|
||||
const { workingDir, configFiles, imageName, hostBindMounts } = this.composeContext;
|
||||
console.log('[SelfUpdate] Validating compose configuration before reapply...');
|
||||
try {
|
||||
await execFileAsync(
|
||||
'docker',
|
||||
buildComposeConfigValidateArgs({ workingDir, imageName, hostBindMounts, configFiles }),
|
||||
{ env, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
} catch (error) {
|
||||
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
|
||||
const stdout = (error as { stdout?: Buffer | string })?.stdout?.toString().trim();
|
||||
this.lastUpdateError =
|
||||
stderr || stdout || (error as Error).message || 'Compose configuration validation failed.';
|
||||
console.error('[SelfUpdate] Compose reapply validation failed:', this.lastUpdateError);
|
||||
return;
|
||||
}
|
||||
|
||||
// No pull and no compose rewrite: the authored image ref is authoritative.
|
||||
// Skip dangling-image prune (nothing was pulled).
|
||||
this.spawnHelper(env, undefined, options?.successMarkerFile, options?.successMarkerContent, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the "last breath" helper container that recreates Sencho (and, when a
|
||||
* repin is staged, copies the rewritten compose file onto the host first).
|
||||
@@ -538,6 +599,7 @@ class SelfUpdateService {
|
||||
composeCopy?: ComposeCopy,
|
||||
successMarkerFile?: string,
|
||||
successMarkerContent?: string,
|
||||
pruneOnUpdateOverride?: boolean,
|
||||
): void {
|
||||
if (!this.composeContext) return;
|
||||
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
|
||||
@@ -551,8 +613,9 @@ class SelfUpdateService {
|
||||
// Opt-out (default ON): after a clean recreate, prune the dangling image
|
||||
// layers the pull orphaned. Read fresh so this node honors its own setting.
|
||||
const stderrTmp = '/tmp/_sencho_err';
|
||||
const pruneOnUpdate =
|
||||
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
|
||||
const pruneOnUpdate = pruneOnUpdateOverride ?? (
|
||||
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'
|
||||
);
|
||||
const composeCmd = buildSelfUpdateComposeCmd(
|
||||
fFlags,
|
||||
serviceName,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { flushPendingSuppressionRetractions } from '../helpers/notificationSuppressionSync';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const INITIAL_DELAY_MS = 30_000;
|
||||
const EVAL_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Background retry for durable mute-replica retractions that failed or were
|
||||
* deferred (offline Pilot/proxy, or remote lacking versioned retraction support).
|
||||
*/
|
||||
export class SuppressionRetractionRetryService {
|
||||
private static instance: SuppressionRetractionRetryService;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private initialTimer: NodeJS.Timeout | null = null;
|
||||
private isProcessing = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SuppressionRetractionRetryService {
|
||||
if (!SuppressionRetractionRetryService.instance) {
|
||||
SuppressionRetractionRetryService.instance = new SuppressionRetractionRetryService();
|
||||
}
|
||||
return SuppressionRetractionRetryService.instance;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.initialTimer = setTimeout(() => {
|
||||
void this.evaluate();
|
||||
this.intervalId = setInterval(() => void this.evaluate(), EVAL_INTERVAL_MS);
|
||||
}, INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.initialTimer) {
|
||||
clearTimeout(this.initialTimer);
|
||||
this.initialTimer = null;
|
||||
}
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full sweep of all pending rows. */
|
||||
async evaluate(): Promise<void> {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
try {
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[SuppressionRetractionRetry:debug] Evaluating pending retractions');
|
||||
}
|
||||
await flushPendingSuppressionRetractions();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[SuppressionRetractionRetry] evaluate error:',
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Targeted flush when a Pilot tunnel or proxy node comes online. */
|
||||
async flushNode(nodeId: number): Promise<void> {
|
||||
try {
|
||||
await flushPendingSuppressionRetractions(nodeId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionRetractionRetry] flushNode ${nodeId} failed:`,
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import DockerController from './DockerController';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { ComposeDoctorService } from './ComposeDoctorService';
|
||||
import { UpdatePreviewService, isMovingTag, filterPreviewForService } from './UpdatePreviewService';
|
||||
import { UpdatePreviewService, isMovingTag, filterPreviewForService, buildDetectionDisabledPreview } from './UpdatePreviewService';
|
||||
import { ImageUpdateService } from './ImageUpdateService';
|
||||
import { buildEffectiveServiceModel, type EffectiveServiceModelResult } from './effectiveServiceModel';
|
||||
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
|
||||
import { isDockerHealthcheckActive } from '../helpers/healthcheckPresence';
|
||||
import { withTimeout } from '../utils/withTimeout';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -97,7 +99,7 @@ export class UpdateGuardService {
|
||||
state: inspect.State?.Status ?? info.State ?? 'unknown',
|
||||
health: inspect.State?.Health?.Status ?? null,
|
||||
exitCode: typeof inspect.State?.ExitCode === 'number' ? inspect.State.ExitCode : null,
|
||||
hasHealthcheck: !!inspect.Config?.Healthcheck?.Test?.length,
|
||||
hasHealthcheck: isDockerHealthcheckActive(inspect.Config?.Healthcheck?.Test),
|
||||
restartPolicy: inspect.HostConfig?.RestartPolicy?.Name || null,
|
||||
mounts,
|
||||
};
|
||||
@@ -137,6 +139,12 @@ export class UpdateGuardService {
|
||||
withTimeout(this.probeContainers(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness sibling probe'))
|
||||
: Promise.resolve<ContainerProbe[] | Errored>([]),
|
||||
this.collect('update preview', stackName, async () => {
|
||||
// Check inside the thunk so the read stays with the getPreview call.
|
||||
// Stack GET/POST update-preview use the same isChecksEnabled gate.
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
const disabled = buildDetectionDisabledPreview(stackName);
|
||||
return serviceName ? filterPreviewForService(disabled, serviceName) : disabled;
|
||||
}
|
||||
const full = await withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness update preview');
|
||||
return serviceName ? filterPreviewForService(full, serviceName) : full;
|
||||
}),
|
||||
@@ -152,7 +160,7 @@ export class UpdateGuardService {
|
||||
driftSignal(drift),
|
||||
containersSignal(containers),
|
||||
healthchecksSignal(containers),
|
||||
updatePreviewSignal(preview === 'error' ? 'error' : preview.summary),
|
||||
updatePreviewSignal(preview === 'error' ? 'error' : preview.summary, preview === 'error' ? undefined : preview.images),
|
||||
buildServicesSignal(preview === 'error' ? 'error' : preview.build_services),
|
||||
backupSlotSignal(backup, now),
|
||||
diskSignal(typeof disk === 'number' ? { usePercent: disk, limitPercent } : 'error'),
|
||||
@@ -218,8 +226,12 @@ export class UpdateGuardService {
|
||||
this.collect('backup info', stackName, () => fsSvc.getBackupInfo(stackName)),
|
||||
this.collect('backup env summary', stackName, () => fsSvc.getBackupEnvSummary(stackName)),
|
||||
this.collect('stack env presence', stackName, () => fsSvc.envExists(stackName)),
|
||||
this.collect('update preview', stackName, () =>
|
||||
withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview')),
|
||||
this.collect('update preview', stackName, async () => {
|
||||
if (!ImageUpdateService.isChecksEnabled()) {
|
||||
return buildDetectionDisabledPreview(stackName);
|
||||
}
|
||||
return withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview');
|
||||
}),
|
||||
this.collect('activity history', stackName, async () => {
|
||||
const events = db.getStackActivity(nodeId, stackName, { limit: 50 });
|
||||
// A successful update is as good a known-good marker as a deploy.
|
||||
|
||||
@@ -10,15 +10,43 @@ import {
|
||||
} from './ImageUpdateService';
|
||||
import {
|
||||
parseImageRef,
|
||||
selectLocalRepoDigest,
|
||||
selectLocalRepoDigests,
|
||||
compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
listRegistryTagsResult,
|
||||
type ParsedRef,
|
||||
type RegistryCredentials,
|
||||
type DigestComparisonResult,
|
||||
} from './registry-api';
|
||||
import {
|
||||
detectImageUpdate,
|
||||
type ImageUpdateDetectResult,
|
||||
type ListRegistryTagsResultFn,
|
||||
type PreviewImageCheckStatus,
|
||||
type SemverBump,
|
||||
computeSemverBump,
|
||||
findNextTag,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
parseSemverTag,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
PREVIEW_TAG_LIST_MAX_TAGS,
|
||||
PREVIEW_TAG_LIST_PAGE_SIZE,
|
||||
type TagEnumOutcome,
|
||||
} from './imageUpdateDetect';
|
||||
|
||||
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
export type { SemverBump, PreviewImageCheckStatus, TagEnumOutcome, ListRegistryTagsResultFn };
|
||||
export {
|
||||
computeSemverBump,
|
||||
findNextTag,
|
||||
isMovingTag,
|
||||
listAllRegistryTagsBounded,
|
||||
parseSemverTag,
|
||||
PREVIEW_TAG_LIST_MAX_PAGES,
|
||||
PREVIEW_TAG_LIST_MAX_TAGS,
|
||||
PREVIEW_TAG_LIST_PAGE_SIZE,
|
||||
};
|
||||
|
||||
/** Stack-level preview authority; absent on older remotes. */
|
||||
export type PreviewCheckStatus = 'ok' | 'partial' | 'failed';
|
||||
|
||||
export interface UpdatePreviewImage {
|
||||
service: string;
|
||||
@@ -26,7 +54,30 @@ export interface UpdatePreviewImage {
|
||||
current_tag: string;
|
||||
next_tag: string | null;
|
||||
has_update: boolean;
|
||||
/** Same-tag content drift; Compose pull can apply without pin change. */
|
||||
digest_update: boolean;
|
||||
/** Higher pinned semver exists; advisory until Compose is edited. */
|
||||
tag_update: boolean;
|
||||
semver_bump: SemverBump;
|
||||
/** Authority of this image's checks; not_checkable for invalid refs. */
|
||||
check_status: PreviewImageCheckStatus;
|
||||
/**
|
||||
* Best operator-facing reason when check_status is not 'ok'/'not_checkable'.
|
||||
* Never paired with a digest-based has_update claim: callers must treat
|
||||
* this as verification-failed / unknown, not as "up to date" or rebuild.
|
||||
*/
|
||||
check_error: string | null;
|
||||
/**
|
||||
* This image's own digest-comparison failure reason, independent of
|
||||
* check_status. A confirmed tag-based update on the SAME image resolves
|
||||
* check_status to 'ok' and nulls check_error even when the digest compare
|
||||
* itself errored (the tag confirmation is authoritative for that image),
|
||||
* but a full-stack apply still pulls/recreates this image's CURRENT tag
|
||||
* content, which was never verified. Callers gating collateral risk to a
|
||||
* full-stack apply must read this field, not check_error, so that
|
||||
* masking never hides an unverified image from the mixed-state gate.
|
||||
*/
|
||||
digest_error: string | null;
|
||||
}
|
||||
|
||||
export type UpdateKind = 'tag' | 'digest' | 'none';
|
||||
@@ -50,6 +101,21 @@ export interface UpdatePreviewSummary {
|
||||
has_build_services: boolean;
|
||||
/** True when a manual update can rebuild local build services (always when has_build_services). */
|
||||
rebuild_available: boolean;
|
||||
/**
|
||||
* Whether every checkable image was verified authoritatively.
|
||||
* Authoritative-negative reconcile requires check_status === 'ok' and !has_update.
|
||||
* Older remotes omit this field; treat absence as non-authoritative.
|
||||
*/
|
||||
check_status: PreviewCheckStatus;
|
||||
/** True when any image's check_status is not 'ok' (excluding not_checkable). */
|
||||
verification_failed: boolean;
|
||||
/** First image check_error when verification_failed; otherwise null. */
|
||||
verification_error: string | null;
|
||||
/**
|
||||
* When true, background detection is disabled on this node and the preview
|
||||
* was not fetched from registries. Optional for older remotes.
|
||||
*/
|
||||
detection_disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePreview {
|
||||
@@ -61,74 +127,6 @@ export interface UpdatePreview {
|
||||
changelog: string | null;
|
||||
}
|
||||
|
||||
interface SemverParts {
|
||||
prefix: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
suffix: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
|
||||
|
||||
export function parseSemverTag(tag: string): SemverParts | null {
|
||||
const m = tag.match(SEMVER_RE);
|
||||
if (!m) return null;
|
||||
return {
|
||||
prefix: m[1] ?? '',
|
||||
major: Number(m[2]),
|
||||
minor: Number(m[3]),
|
||||
patch: Number(m[4]),
|
||||
suffix: m[5] ?? '',
|
||||
raw: tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag is "moving" when restoring the compose file would not revert the image
|
||||
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
|
||||
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
|
||||
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
|
||||
* restores the exact tag.
|
||||
*/
|
||||
export function isMovingTag(tag: string): boolean {
|
||||
return parseSemverTag(tag) === null;
|
||||
}
|
||||
|
||||
function compareSemver(a: SemverParts, b: SemverParts): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
|
||||
const current = parseSemverTag(currentTag);
|
||||
if (!current) return null;
|
||||
let best: SemverParts | null = null;
|
||||
for (const tag of availableTags) {
|
||||
const parsed = parseSemverTag(tag);
|
||||
if (!parsed) continue;
|
||||
if (parsed.prefix !== current.prefix) continue;
|
||||
if (parsed.suffix !== current.suffix) continue;
|
||||
if (compareSemver(parsed, current) <= 0) continue;
|
||||
if (!best || compareSemver(parsed, best) > 0) best = parsed;
|
||||
}
|
||||
return best ? best.raw : null;
|
||||
}
|
||||
|
||||
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
|
||||
if (!nextTag) return 'none';
|
||||
if (nextTag === currentTag) return 'patch';
|
||||
const current = parseSemverTag(currentTag);
|
||||
const next = parseSemverTag(nextTag);
|
||||
if (!current || !next) return 'unknown';
|
||||
if (next.major > current.major) return 'major';
|
||||
if (next.minor > current.minor) return 'minor';
|
||||
if (next.patch > current.patch) return 'patch';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function maxBump(a: SemverBump, b: SemverBump): SemverBump {
|
||||
// Ranking: none < unknown < patch < minor < major.
|
||||
// unknown ranks below real semver bumps so a single unparseable tag never masks
|
||||
@@ -164,18 +162,81 @@ async function loadStackImages(
|
||||
return extractServiceImagesFromCompose(composeContent, merged);
|
||||
}
|
||||
|
||||
export type LocalDigestEmptyReason = 'not_checkable' | 'inspect_failed' | 'unresolved';
|
||||
|
||||
export interface LocalDigestInfo {
|
||||
digest: string | null;
|
||||
/** All usable RepoDigests for the image ref; compared as a set against the remote tag. */
|
||||
digests: string[];
|
||||
platform: { os: string; architecture: string };
|
||||
/**
|
||||
* Why digests is empty. `not_checkable` mirrors the scanner (no RepoDigests /
|
||||
* locally built) and must not surface as verification-failed. Inspect failure
|
||||
* and unresolved selection do.
|
||||
*/
|
||||
emptyReason: LocalDigestEmptyReason | null;
|
||||
}
|
||||
|
||||
export interface ComputePreviewDeps {
|
||||
getLocalDigest: (imageRef: string, parsed: ParsedRef) => Promise<LocalDigestInfo>;
|
||||
compareDigest: typeof compareLocalToRemoteTag;
|
||||
listRegistryTags: typeof listRegistryTags;
|
||||
listRegistryTagsResult: ListRegistryTagsResultFn;
|
||||
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
|
||||
}
|
||||
|
||||
function imageFromDetect(
|
||||
service: string,
|
||||
imageRef: string,
|
||||
currentTag: string,
|
||||
detected: ImageUpdateDetectResult,
|
||||
): UpdatePreviewImage {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: currentTag,
|
||||
next_tag: detected.nextTag,
|
||||
has_update: detected.hasUpdate,
|
||||
digest_update: detected.digestUpdate,
|
||||
tag_update: detected.tagUpdate,
|
||||
semver_bump: detected.semverBump,
|
||||
check_status: detected.checkStatus,
|
||||
check_error: detected.reason,
|
||||
digest_error: detected.digestError,
|
||||
};
|
||||
}
|
||||
|
||||
function notCheckableImage(service: string, imageRef: string): UpdatePreviewImage {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'not_checkable',
|
||||
check_error: null,
|
||||
digest_error: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Local digest could not be established at all; never reaches the registry. */
|
||||
function failedLocalDigestImage(service: string, imageRef: string, currentTag: string, reason: string): UpdatePreviewImage {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: currentTag,
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
digest_update: false,
|
||||
tag_update: false,
|
||||
semver_bump: 'none',
|
||||
check_status: 'failed',
|
||||
check_error: reason,
|
||||
digest_error: reason,
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeImagePreview(
|
||||
service: string,
|
||||
imageRef: string,
|
||||
@@ -183,52 +244,41 @@ export async function computeImagePreview(
|
||||
): Promise<UpdatePreviewImage> {
|
||||
const parsed = parseImageRef(imageRef);
|
||||
if (!parsed) {
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: 'unknown',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
semver_bump: 'none',
|
||||
};
|
||||
return notCheckableImage(service, imageRef);
|
||||
}
|
||||
|
||||
const credentials = await deps.getCredentials(parsed.registry);
|
||||
|
||||
// Digest-based: is a new build of the SAME tag available? A comparison error
|
||||
// (network failure, malformed manifest) fails soft: it never claims a
|
||||
// digest-based update, it only skips it.
|
||||
const localInfo = await deps.getLocalDigest(imageRef, parsed);
|
||||
const [comparison, tags] = await Promise.all([
|
||||
localInfo.digest
|
||||
? deps.compareDigest(localInfo.digest, parsed.registry, parsed.repo, parsed.tag, localInfo.platform, credentials)
|
||||
: Promise.resolve<DigestComparisonResult>({ kind: 'error', reason: 'No local registry digest available' }),
|
||||
deps.listRegistryTags(parsed.registry, parsed.repo, credentials),
|
||||
]);
|
||||
const digestUpdate = comparison.kind === 'update';
|
||||
|
||||
// Tag-based: is a higher semver tag available?
|
||||
const nextTag = findNextTag(parsed.tag, tags);
|
||||
|
||||
const hasUpdate = digestUpdate || nextTag !== null;
|
||||
let semverBump: SemverBump = 'none';
|
||||
let resolvedNext: string | null = null;
|
||||
if (nextTag) {
|
||||
resolvedNext = nextTag;
|
||||
semverBump = computeSemverBump(parsed.tag, nextTag);
|
||||
} else if (digestUpdate) {
|
||||
resolvedNext = parsed.tag;
|
||||
semverBump = 'patch';
|
||||
// A locally-built / non-registry-backed image (no RepoDigests at all) is
|
||||
// not_checkable, matching ImageUpdateService.checkImage: it must never be
|
||||
// funneled into detectImageUpdate's "no local digest" error path, which
|
||||
// would misreport it as a verification failure instead of not applicable.
|
||||
if (localInfo.emptyReason === 'not_checkable') {
|
||||
return notCheckableImage(service, imageRef);
|
||||
}
|
||||
|
||||
return {
|
||||
service,
|
||||
image: imageRef,
|
||||
current_tag: parsed.tag,
|
||||
next_tag: resolvedNext,
|
||||
has_update: hasUpdate,
|
||||
semver_bump: semverBump,
|
||||
};
|
||||
// Inspect failure and unresolved RepoDigests never reach the registry, and
|
||||
// detectImageUpdate's generic "no local digest" reason would blur these two
|
||||
// distinct causes together; keep the specific reason, matching what
|
||||
// ImageUpdateService.checkImage reports for the same two cases.
|
||||
if (localInfo.emptyReason === 'inspect_failed') {
|
||||
return failedLocalDigestImage(service, imageRef, parsed.tag, 'Failed to inspect local image');
|
||||
}
|
||||
if (localInfo.emptyReason === 'unresolved') {
|
||||
return failedLocalDigestImage(service, imageRef, parsed.tag, 'Could not resolve a local registry digest');
|
||||
}
|
||||
const detected = await detectImageUpdate({
|
||||
localDigests: localInfo.digests,
|
||||
platform: localInfo.platform,
|
||||
registry: parsed.registry,
|
||||
repo: parsed.repo,
|
||||
tag: parsed.tag,
|
||||
credentials,
|
||||
deps: {
|
||||
compareDigest: deps.compareDigest,
|
||||
listRegistryTagsResult: deps.listRegistryTagsResult,
|
||||
},
|
||||
});
|
||||
return imageFromDetect(service, imageRef, parsed.tag, detected);
|
||||
}
|
||||
|
||||
function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
@@ -244,6 +294,16 @@ function buildRollbackTarget(image: string, currentTag: string): string | null {
|
||||
return `${base}:${currentTag}`;
|
||||
}
|
||||
|
||||
export function rollupPreviewCheckStatus(images: UpdatePreviewImage[]): PreviewCheckStatus {
|
||||
const checkable = images.filter((i) => i.check_status !== 'not_checkable');
|
||||
if (checkable.length === 0) return 'ok';
|
||||
const allFailed = checkable.every((i) => i.check_status === 'failed');
|
||||
if (allFailed) return 'failed';
|
||||
const allOk = checkable.every((i) => i.check_status === 'ok');
|
||||
if (allOk) return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
export function buildSummary(
|
||||
stackName: string,
|
||||
images: UpdatePreviewImage[],
|
||||
@@ -266,6 +326,7 @@ export function buildSummary(
|
||||
: updated.some(i => i.next_tag !== null && i.next_tag !== i.current_tag)
|
||||
? 'tag'
|
||||
: 'digest';
|
||||
const verificationError = images.find(i => i.check_error)?.check_error ?? null;
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images,
|
||||
@@ -281,12 +342,24 @@ export function buildSummary(
|
||||
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
|
||||
has_build_services: hasBuildServices,
|
||||
rebuild_available: hasBuildServices,
|
||||
check_status: rollupPreviewCheckStatus(images),
|
||||
verification_failed: verificationError !== null,
|
||||
verification_error: verificationError,
|
||||
},
|
||||
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
|
||||
changelog: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when a preview is safe to clear sticky scanner state. */
|
||||
export function isAuthoritativeNegativePreview(preview: UpdatePreview): boolean {
|
||||
// Every declared image must be explicitly ok. Mixed ok + not_checkable must
|
||||
// not clear sticky rows that may still track unresolved services.
|
||||
return preview.images.length > 0
|
||||
&& preview.images.every((i) => i.check_status === 'ok')
|
||||
&& preview.summary.has_update === false;
|
||||
}
|
||||
|
||||
/** Filter a full-stack preview down to one service's images and recompute the summary from that subset. */
|
||||
export function filterPreviewForService(preview: UpdatePreview, serviceName: string): UpdatePreview {
|
||||
const images = preview.images.filter(i => i.service === serviceName);
|
||||
@@ -294,6 +367,33 @@ export function filterPreviewForService(preview: UpdatePreview, serviceName: str
|
||||
return buildSummary(preview.stack_name, images, buildServices);
|
||||
}
|
||||
|
||||
/** Minimal preview when node-scoped image update detection is disabled. */
|
||||
export function buildDetectionDisabledPreview(stackName: string): UpdatePreview {
|
||||
return {
|
||||
stack_name: stackName,
|
||||
images: [],
|
||||
build_services: [],
|
||||
summary: {
|
||||
has_update: false,
|
||||
primary_image: null,
|
||||
current_tag: null,
|
||||
next_tag: null,
|
||||
semver_bump: 'none',
|
||||
update_kind: 'none',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: false,
|
||||
rebuild_available: false,
|
||||
check_status: 'ok',
|
||||
verification_failed: false,
|
||||
verification_error: null,
|
||||
detection_disabled: true,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class UpdatePreviewService {
|
||||
private static instance: UpdatePreviewService;
|
||||
|
||||
@@ -317,21 +417,44 @@ export class UpdatePreviewService {
|
||||
const deps: ComputePreviewDeps = {
|
||||
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
|
||||
compareDigest: compareLocalToRemoteTag,
|
||||
listRegistryTags,
|
||||
listRegistryTagsResult,
|
||||
getLocalDigest: async (imageRef: string, parsed: ParsedRef): Promise<LocalDigestInfo> => {
|
||||
try {
|
||||
const inspect = await docker.getDocker().getImage(imageRef).inspect();
|
||||
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
||||
const digest = selectLocalRepoDigest(repoDigests, parsed);
|
||||
return { digest, platform: { os: inspect.Os, architecture: inspect.Architecture } };
|
||||
} catch {
|
||||
return { digest: null, platform: { os: '', architecture: '' } };
|
||||
if (repoDigests.length === 0) {
|
||||
return {
|
||||
digests: [],
|
||||
platform: { os: inspect.Os, architecture: inspect.Architecture },
|
||||
emptyReason: 'not_checkable',
|
||||
};
|
||||
}
|
||||
const digests = selectLocalRepoDigests(repoDigests, parsed);
|
||||
return {
|
||||
digests,
|
||||
platform: { os: inspect.Os, architecture: inspect.Architecture },
|
||||
emptyReason: digests.length === 0 ? 'unresolved' : null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[UpdatePreview] local image inspect failed for %s', imageRef, err);
|
||||
return { digests: [], platform: { os: '', architecture: '' }, emptyReason: 'inspect_failed' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Memoize service-independent detection by image ref so shared images
|
||||
// hit the registry once, then attach each service name separately.
|
||||
const detectByRef = new Map<string, Promise<UpdatePreviewImage>>();
|
||||
const results = await Promise.all(
|
||||
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
|
||||
stackImages.map(async ({ service, image }) => {
|
||||
let shared = detectByRef.get(image);
|
||||
if (!shared) {
|
||||
shared = computeImagePreview('_shared_', image, deps);
|
||||
detectByRef.set(image, shared);
|
||||
}
|
||||
const base = await shared;
|
||||
return { ...base, service };
|
||||
}),
|
||||
);
|
||||
return buildSummary(stackName, results, buildServices);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { parseMissingRequiredVars } from '../helpers/envVarParse';
|
||||
import { isComposeHealthcheckActive } from '../helpers/healthcheckPresence';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
@@ -75,9 +76,7 @@ function parseServiceSpec(name: string, raw: unknown): EffectiveServiceSpec {
|
||||
const svc = (raw ?? {}) as Record<string, unknown>;
|
||||
const deploy = (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined;
|
||||
const healthcheck = svc.healthcheck;
|
||||
const hasHealthcheck = !!healthcheck
|
||||
&& typeof healthcheck === 'object'
|
||||
&& (healthcheck as Record<string, unknown>).disable !== true;
|
||||
const hasHealthcheck = isComposeHealthcheckActive(healthcheck);
|
||||
return {
|
||||
name,
|
||||
declaredImage: asString(svc.image) ?? null,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Collect per-service effective healthcheck evidence for Compose Doctor.
|
||||
* Structural facts only: never returns or logs Healthcheck.Test command text.
|
||||
*/
|
||||
import DockerController from '../DockerController';
|
||||
import { filterContainersByComposeService } from '../../helpers/composeServiceMatch';
|
||||
import { isDockerHealthcheckActive } from '../../helpers/healthcheckPresence';
|
||||
import type { EffectiveModel } from '../preflight/effectiveModel';
|
||||
import type { ServiceHealthcheckEvidence } from '../preflight/types';
|
||||
import { mapWithConcurrency } from '../../utils/mapWithConcurrency';
|
||||
import { getErrorMessage } from '../../utils/errors';
|
||||
import { sanitizeForLog } from '../../utils/safeLog';
|
||||
|
||||
const INSPECT_CONCURRENCY = 8;
|
||||
|
||||
type ListedContainer = {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
Labels?: Record<string, string>;
|
||||
Image?: string;
|
||||
};
|
||||
|
||||
type ReplicaProbe = {
|
||||
hasHealthcheck: boolean;
|
||||
imageMatches: boolean;
|
||||
inspectFailed: boolean;
|
||||
};
|
||||
|
||||
type ImageEvidence = 'inherited' | 'absent' | 'unverifiable';
|
||||
|
||||
function evidence(
|
||||
state: ServiceHealthcheckEvidence['state'],
|
||||
origin: ServiceHealthcheckEvidence['origin'],
|
||||
consistentReplicas: boolean | null,
|
||||
): ServiceHealthcheckEvidence {
|
||||
return { state, origin, consistentReplicas };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective healthcheck coverage for each service in the model.
|
||||
* When `nodeStateAvailable` is false, services that still need Docker evidence
|
||||
* become unverifiable without listing or inspecting containers/images.
|
||||
*/
|
||||
export async function collectServiceHealthcheckEvidence(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
model: EffectiveModel,
|
||||
nodeStateAvailable: boolean,
|
||||
): Promise<Record<string, ServiceHealthcheckEvidence>> {
|
||||
const out: Record<string, ServiceHealthcheckEvidence> = {};
|
||||
|
||||
const needsDocker = model.services.some(s =>
|
||||
s.composeHealthcheck !== 'active' && s.composeHealthcheck !== 'disabled');
|
||||
|
||||
let listed: ListedContainer[] = [];
|
||||
let listFailed = false;
|
||||
// Compose's top-level `name:` becomes com.docker.compose.project; that often
|
||||
// differs from the Sencho stack directory name used as stackName.
|
||||
const projectLabel = model.projectName || stackName;
|
||||
if (nodeStateAvailable && needsDocker) {
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
listed = await docker.listContainers({
|
||||
all: true,
|
||||
filters: { label: [`com.docker.compose.project=${projectLabel}`] },
|
||||
}) as ListedContainer[];
|
||||
} catch (err) {
|
||||
listFailed = true;
|
||||
console.warn(
|
||||
'[ComposeDoctor] Healthcheck container list failed for %s:',
|
||||
sanitizeForLog(projectLabel),
|
||||
sanitizeForLog(getErrorMessage(err, 'unknown')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const svc of model.services) {
|
||||
if (svc.composeHealthcheck === 'active') {
|
||||
out[svc.name] = evidence('compose-declared', 'compose', null);
|
||||
continue;
|
||||
}
|
||||
if (svc.composeHealthcheck === 'disabled') {
|
||||
out[svc.name] = evidence('explicitly-disabled', 'compose', null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nodeStateAvailable) {
|
||||
out[svc.name] = evidence('unverifiable', 'none', null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listFailed) {
|
||||
// Container list failed, but a local image inspect may still succeed.
|
||||
out[svc.name] = await evidenceFromLocalImage(nodeId, svc.image, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const scoped = filterContainersByComposeService(listed, svc.name);
|
||||
if (scoped.length > 0) {
|
||||
const replicas = await mapWithConcurrency(scoped, INSPECT_CONCURRENCY, (c) =>
|
||||
probeReplica(nodeId, c, svc.image));
|
||||
const fromRuntime = await resolveRuntimeEvidence(replicas);
|
||||
if (fromRuntime) {
|
||||
out[svc.name] = fromRuntime;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No suitable runtime evidence: local image or unverifiable.
|
||||
out[svc.name] = await evidenceFromLocalImage(nodeId, svc.image, null);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide from inspected, image-matched replicas.
|
||||
* Returns null when the caller should fall through to a generic local-image lookup
|
||||
* (all inspects failed, or every replica is a stale/mismatched image).
|
||||
*/
|
||||
function resolveRuntimeEvidence(
|
||||
replicas: ReplicaProbe[],
|
||||
): ServiceHealthcheckEvidence | null {
|
||||
const inspected = replicas.filter(r => !r.inspectFailed);
|
||||
if (inspected.length === 0) return null;
|
||||
|
||||
const usable = inspected.filter(r => r.imageMatches);
|
||||
if (usable.length === 0) return null;
|
||||
|
||||
const withHc = usable.filter(r => r.hasHealthcheck).length;
|
||||
const withoutHc = usable.length - withHc;
|
||||
const partial = inspected.length < replicas.length;
|
||||
|
||||
if (withHc > 0 && withoutHc > 0) {
|
||||
return evidence('inconsistent-replicas', 'runtime', false);
|
||||
}
|
||||
|
||||
if (withHc === usable.length) {
|
||||
// Incomplete inspection: do not claim full coverage.
|
||||
if (partial) return evidence('unverifiable', 'runtime', null);
|
||||
return evidence('runtime-inherited', 'runtime', true);
|
||||
}
|
||||
|
||||
// All usable replicas lack an effective healthcheck. Do not upgrade a verified
|
||||
// runtime gap to local-image-inherited; live replicas are authoritative.
|
||||
if (partial) return evidence('unverifiable', 'runtime', null);
|
||||
return evidence('absent', 'runtime', true);
|
||||
}
|
||||
|
||||
async function evidenceFromLocalImage(
|
||||
nodeId: number,
|
||||
image: string | undefined,
|
||||
consistentReplicas: boolean | null,
|
||||
): Promise<ServiceHealthcheckEvidence> {
|
||||
if (!image) return evidence('unverifiable', 'none', consistentReplicas);
|
||||
|
||||
const imageKind = await inspectLocalImage(nodeId, image);
|
||||
if (imageKind === 'inherited') {
|
||||
return evidence('local-image-inherited', 'local-image', consistentReplicas);
|
||||
}
|
||||
if (imageKind === 'absent') {
|
||||
return evidence('absent', 'local-image', consistentReplicas);
|
||||
}
|
||||
return evidence('unverifiable', 'none', consistentReplicas);
|
||||
}
|
||||
|
||||
async function probeReplica(
|
||||
nodeId: number,
|
||||
listed: ListedContainer,
|
||||
declaredImage: string | undefined,
|
||||
): Promise<ReplicaProbe> {
|
||||
try {
|
||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||
const inspect = await docker.getContainer(listed.Id).inspect();
|
||||
const test = inspect.Config?.Healthcheck?.Test;
|
||||
const hasHealthcheck = isDockerHealthcheckActive(test);
|
||||
const runtimeImage = typeof inspect.Config?.Image === 'string' ? inspect.Config.Image : listed.Image;
|
||||
const imageMatches = !declaredImage
|
||||
|| !runtimeImage
|
||||
|| runtimeImage === declaredImage;
|
||||
return { hasHealthcheck, imageMatches, inspectFailed: false };
|
||||
} catch (err) {
|
||||
if ((err as { statusCode?: number })?.statusCode === 404) {
|
||||
return { hasHealthcheck: false, imageMatches: false, inspectFailed: true };
|
||||
}
|
||||
console.warn(
|
||||
'[ComposeDoctor] Healthcheck container inspect failed:',
|
||||
sanitizeForLog(getErrorMessage(err, 'unknown')),
|
||||
);
|
||||
return { hasHealthcheck: false, imageMatches: false, inspectFailed: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectLocalImage(
|
||||
nodeId: number,
|
||||
imageRef: string,
|
||||
): Promise<ImageEvidence> {
|
||||
try {
|
||||
const { inspect } = await DockerController.getInstance(nodeId).inspectImage(imageRef);
|
||||
const test = (inspect as { Config?: { Healthcheck?: { Test?: unknown } } })?.Config?.Healthcheck?.Test;
|
||||
return isDockerHealthcheckActive(test) ? 'inherited' : 'absent';
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[ComposeDoctor] Healthcheck image inspect failed for %s:',
|
||||
sanitizeForLog(imageRef),
|
||||
sanitizeForLog(getErrorMessage(err, 'unknown')),
|
||||
);
|
||||
return 'unverifiable';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Shared image-update detection used by both persisted sidebar status
|
||||
* (ImageUpdateService.checkImage) and Fleet/Anatomy preview
|
||||
* (UpdatePreviewService.computeImagePreview).
|
||||
*
|
||||
* An update is available when either:
|
||||
* 1. the local digest no longer matches the registry manifest for the
|
||||
* currently declared tag (digestUpdate; Compose-actionable), or
|
||||
* 2. a higher pinned semver tag exists in a complete bounded tag list
|
||||
* (tagUpdate; advisory only until Compose is edited).
|
||||
*/
|
||||
import {
|
||||
compareLocalToRemoteTag,
|
||||
listRegistryTagsResult,
|
||||
type DigestComparisonResult,
|
||||
type RegistryCredentials,
|
||||
type TagListResult,
|
||||
} from './registry-api';
|
||||
|
||||
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||
|
||||
/** Per-image check confidence for preview authority and scanner persistence. */
|
||||
export type PreviewImageCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
|
||||
interface SemverParts {
|
||||
prefix: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
suffix: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
|
||||
|
||||
/** Max pages when enumerating tags for a pinned-semver authoritative-negative. */
|
||||
export const PREVIEW_TAG_LIST_MAX_PAGES = 20;
|
||||
/** Max tags accumulated across pages for the same purpose. */
|
||||
export const PREVIEW_TAG_LIST_MAX_TAGS = 2000;
|
||||
/** Per-page size passed to listRegistryTagsResult. */
|
||||
export const PREVIEW_TAG_LIST_PAGE_SIZE = 100;
|
||||
|
||||
export function parseSemverTag(tag: string): SemverParts | null {
|
||||
const m = tag.match(SEMVER_RE);
|
||||
if (!m) return null;
|
||||
return {
|
||||
prefix: m[1] ?? '',
|
||||
major: Number(m[2]),
|
||||
minor: Number(m[3]),
|
||||
patch: Number(m[4]),
|
||||
suffix: m[5] ?? '',
|
||||
raw: tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag is "moving" when restoring the compose file would not revert the image
|
||||
* behind it: `latest`, a branch name, or an unpinned major/minor like `1.25`.
|
||||
* Only a fully-pinned semver tag (X.Y.Z, optionally `v`-prefixed and/or with a
|
||||
* `-prerelease` suffix) is treated as immutable, matching how a file rollback
|
||||
* restores the exact tag.
|
||||
*/
|
||||
export function isMovingTag(tag: string): boolean {
|
||||
return parseSemverTag(tag) === null;
|
||||
}
|
||||
|
||||
function compareSemver(a: SemverParts, b: SemverParts): number {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
|
||||
const current = parseSemverTag(currentTag);
|
||||
if (!current) return null;
|
||||
let best: SemverParts | null = null;
|
||||
for (const tag of availableTags) {
|
||||
const parsed = parseSemverTag(tag);
|
||||
if (!parsed) continue;
|
||||
if (parsed.prefix !== current.prefix) continue;
|
||||
if (parsed.suffix !== current.suffix) continue;
|
||||
if (compareSemver(parsed, current) <= 0) continue;
|
||||
if (!best || compareSemver(parsed, best) > 0) best = parsed;
|
||||
}
|
||||
return best ? best.raw : null;
|
||||
}
|
||||
|
||||
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
|
||||
if (!nextTag) return 'none';
|
||||
if (nextTag === currentTag) return 'patch';
|
||||
const current = parseSemverTag(currentTag);
|
||||
const next = parseSemverTag(nextTag);
|
||||
if (!current || !next) return 'unknown';
|
||||
if (next.major > current.major) return 'major';
|
||||
if (next.minor > current.minor) return 'minor';
|
||||
if (next.patch > current.patch) return 'patch';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export type ListRegistryTagsResultFn = (
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
opts?: { limit?: number; cursor?: string },
|
||||
) => Promise<TagListResult>;
|
||||
|
||||
export type TagEnumOutcome =
|
||||
| { kind: 'complete'; tags: string[] }
|
||||
| { kind: 'incomplete'; tags: string[]; reason: string }
|
||||
| { kind: 'error'; reason: string }
|
||||
| { kind: 'skipped' };
|
||||
|
||||
/**
|
||||
* Enumerate tags with bounded pagination. Hitting the page/tag cap while a
|
||||
* nextCursor remains is incomplete (non-authoritative), not a successful empty
|
||||
* or "no newer tag" result.
|
||||
*/
|
||||
export async function listAllRegistryTagsBounded(
|
||||
listFn: ListRegistryTagsResultFn,
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials: RegistryCredentials | null,
|
||||
opts: { maxPages?: number; maxTags?: number; pageSize?: number } = {},
|
||||
): Promise<TagEnumOutcome> {
|
||||
const maxPages = opts.maxPages ?? PREVIEW_TAG_LIST_MAX_PAGES;
|
||||
const maxTags = opts.maxTags ?? PREVIEW_TAG_LIST_MAX_TAGS;
|
||||
const pageSize = opts.pageSize ?? PREVIEW_TAG_LIST_PAGE_SIZE;
|
||||
const tags: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const result = await listFn(registry, repo, credentials, { limit: pageSize, cursor });
|
||||
if (!result.ok) {
|
||||
return { kind: 'error', reason: result.message };
|
||||
}
|
||||
tags.push(...result.tags);
|
||||
if (tags.length > maxTags) {
|
||||
return {
|
||||
kind: 'incomplete',
|
||||
tags: tags.slice(0, maxTags),
|
||||
reason: `Tag list exceeded ${maxTags} tags before pagination completed`,
|
||||
};
|
||||
}
|
||||
if (!result.nextCursor) {
|
||||
return { kind: 'complete', tags };
|
||||
}
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
return {
|
||||
kind: 'incomplete',
|
||||
tags,
|
||||
reason: `Tag list exceeded ${maxPages} pages before pagination completed`,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageUpdateDetectInput {
|
||||
localDigests: readonly string[];
|
||||
platform: { os: string; architecture: string };
|
||||
registry: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
credentials: RegistryCredentials | null;
|
||||
}
|
||||
|
||||
export interface ImageUpdateDetectResult {
|
||||
hasUpdate: boolean;
|
||||
digestUpdate: boolean;
|
||||
tagUpdate: boolean;
|
||||
nextTag: string | null;
|
||||
digestError: string | null;
|
||||
tagEnumKind: 'complete' | 'incomplete' | 'error' | 'skipped';
|
||||
tagEnumReason: string | null;
|
||||
checkStatus: PreviewImageCheckStatus;
|
||||
/** Best operator-facing uncertainty reason for lastError / tooltips. */
|
||||
reason: string | null;
|
||||
semverBump: SemverBump;
|
||||
}
|
||||
|
||||
export interface DetectImageUpdateDeps {
|
||||
compareDigest?: typeof compareLocalToRemoteTag;
|
||||
listRegistryTagsResult?: ListRegistryTagsResultFn;
|
||||
}
|
||||
|
||||
export function resolveImageCheckStatus(args: {
|
||||
digest: DigestComparisonResult['kind'];
|
||||
tagApplicable: boolean;
|
||||
tagOutcome: TagEnumOutcome;
|
||||
hasUpdate: boolean;
|
||||
}): PreviewImageCheckStatus {
|
||||
const { digest, tagApplicable, tagOutcome, hasUpdate } = args;
|
||||
|
||||
if (digest === 'update') return 'ok';
|
||||
|
||||
if (!tagApplicable) {
|
||||
return digest === 'match' ? 'ok' : 'failed';
|
||||
}
|
||||
|
||||
if (tagOutcome.kind === 'complete') {
|
||||
if (hasUpdate) return 'ok';
|
||||
if (digest === 'match') return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (tagOutcome.kind === 'incomplete') {
|
||||
if (hasUpdate) return 'ok';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
if (hasUpdate) return 'partial';
|
||||
if (digest === 'match') return 'partial';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function uncertaintyReason(
|
||||
checkStatus: PreviewImageCheckStatus,
|
||||
digestError: string | null,
|
||||
tagEnumReason: string | null,
|
||||
): string | null {
|
||||
if (checkStatus === 'ok' || checkStatus === 'not_checkable') return null;
|
||||
return tagEnumReason ?? digestError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core availability check shared by preview and persistence.
|
||||
*/
|
||||
export async function detectImageUpdate(args: ImageUpdateDetectInput & {
|
||||
deps?: DetectImageUpdateDeps;
|
||||
}): Promise<ImageUpdateDetectResult> {
|
||||
const compareDigest = args.deps?.compareDigest ?? compareLocalToRemoteTag;
|
||||
const listFn = args.deps?.listRegistryTagsResult ?? listRegistryTagsResult;
|
||||
const tagApplicable = !isMovingTag(args.tag);
|
||||
|
||||
const comparisonPromise: Promise<DigestComparisonResult> = args.localDigests.length > 0
|
||||
? compareDigest(
|
||||
args.localDigests,
|
||||
args.registry,
|
||||
args.repo,
|
||||
args.tag,
|
||||
args.platform,
|
||||
args.credentials,
|
||||
)
|
||||
: Promise.resolve({ kind: 'error', reason: 'No local registry digest available' });
|
||||
|
||||
const tagPromise: Promise<TagEnumOutcome> = tagApplicable
|
||||
? listAllRegistryTagsBounded(listFn, args.registry, args.repo, args.credentials)
|
||||
: Promise.resolve({ kind: 'skipped' });
|
||||
|
||||
const [comparison, tagOutcome] = await Promise.all([comparisonPromise, tagPromise]);
|
||||
|
||||
let nextTag: string | null = null;
|
||||
if (tagOutcome.kind === 'complete' || tagOutcome.kind === 'incomplete') {
|
||||
nextTag = findNextTag(args.tag, tagOutcome.tags);
|
||||
}
|
||||
|
||||
const digestUpdate = comparison.kind === 'update';
|
||||
const tagUpdate = nextTag !== null;
|
||||
const hasUpdate = digestUpdate || tagUpdate;
|
||||
|
||||
let resolvedNext: string | null = null;
|
||||
let semverBump: SemverBump = 'none';
|
||||
if (nextTag) {
|
||||
resolvedNext = nextTag;
|
||||
semverBump = computeSemverBump(args.tag, nextTag);
|
||||
} else if (digestUpdate) {
|
||||
resolvedNext = args.tag;
|
||||
semverBump = 'patch';
|
||||
}
|
||||
|
||||
const digestError = comparison.kind === 'error' ? comparison.reason : null;
|
||||
const tagEnumKind = tagOutcome.kind;
|
||||
const tagEnumReason = tagOutcome.kind === 'incomplete' || tagOutcome.kind === 'error'
|
||||
? tagOutcome.reason
|
||||
: null;
|
||||
|
||||
const checkStatus = resolveImageCheckStatus({
|
||||
digest: comparison.kind,
|
||||
tagApplicable,
|
||||
tagOutcome,
|
||||
hasUpdate,
|
||||
});
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
digestUpdate,
|
||||
tagUpdate,
|
||||
nextTag: resolvedNext,
|
||||
digestError,
|
||||
tagEnumKind,
|
||||
tagEnumReason,
|
||||
checkStatus,
|
||||
reason: uncertaintyReason(checkStatus, digestError, tagEnumReason),
|
||||
semverBump,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
* are handled by the caller, not here.
|
||||
*/
|
||||
|
||||
import { classifyComposeHealthcheck } from '../../helpers/healthcheckPresence';
|
||||
|
||||
/** A host-published port range declared by a service (start==end for one port). */
|
||||
export interface EffPortSpec {
|
||||
startPort: number;
|
||||
@@ -55,7 +57,13 @@ export interface EffService {
|
||||
privileged: boolean;
|
||||
networkMode?: string;
|
||||
restart?: string;
|
||||
/**
|
||||
* True when the rendered Compose model declares an active healthcheck.
|
||||
* False for absent, `disable: true`, and `test: NONE` / `["NONE"]`.
|
||||
*/
|
||||
hasHealthcheck: boolean;
|
||||
/** Compose-layer classification used by healthcheck evidence collection. */
|
||||
composeHealthcheck: 'active' | 'disabled' | 'absent';
|
||||
/** Raw deploy block (preflight uses key presence; Drift also reads restart_policy.condition). Undefined = none. */
|
||||
deploy?: Record<string, unknown>;
|
||||
containerName?: string;
|
||||
@@ -393,10 +401,7 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
|
||||
: [];
|
||||
const { binds, named } = parseVolumes(svc.volumes);
|
||||
const storageMounts = parseStorageMounts(svc.volumes, svc.tmpfs);
|
||||
const healthcheck = svc.healthcheck;
|
||||
const hasHealthcheck = !!healthcheck
|
||||
&& typeof healthcheck === 'object'
|
||||
&& (healthcheck as Record<string, unknown>).disable !== true;
|
||||
const composeHealthcheck = classifyComposeHealthcheck(svc.healthcheck);
|
||||
services.push({
|
||||
name,
|
||||
image: str(svc.image),
|
||||
@@ -407,7 +412,8 @@ export function parseEffectiveModel(parsed: unknown, fallbackProjectName: string
|
||||
privileged: svc.privileged === true,
|
||||
networkMode: str(svc.network_mode),
|
||||
restart: str(svc.restart),
|
||||
hasHealthcheck,
|
||||
hasHealthcheck: composeHealthcheck === 'active',
|
||||
composeHealthcheck,
|
||||
deploy: (svc.deploy && typeof svc.deploy === 'object') ? svc.deploy as Record<string, unknown> : undefined,
|
||||
containerName: str(svc.container_name),
|
||||
user: str(svc.user),
|
||||
|
||||
@@ -7,6 +7,18 @@ import { classifyMissingExternalNetworks } from '../network/missingExternalNetwo
|
||||
/** Higher number = more severe. Used to derive a run's overall status. */
|
||||
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
|
||||
|
||||
/**
|
||||
* Rule IDs that are informational notes, not issue findings. They appear in the
|
||||
* report for context but do not affect All Clear, active severity, or Update Guard.
|
||||
*/
|
||||
export const PREFLIGHT_NOTE_RULE_IDS: ReadonlySet<string> = new Set([
|
||||
'healthcheck-inherited',
|
||||
]);
|
||||
|
||||
export function isPreflightNoteFinding(ruleId: string): boolean {
|
||||
return PREFLIGHT_NOTE_RULE_IDS.has(ruleId);
|
||||
}
|
||||
|
||||
/** The one rule whose message doubles as the report's render error. Shared so the
|
||||
* service that reconstructs renderError from it cannot drift from the rule id. */
|
||||
export const RENDER_FAILED_RULE_ID = 'render-failed';
|
||||
@@ -373,15 +385,102 @@ const noHealthcheck: PreflightRule = {
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return ctx.model.services
|
||||
.filter(s => !s.hasHealthcheck)
|
||||
.filter(s => ctx.healthchecks[s.name]?.state === 'absent')
|
||||
.map(s => {
|
||||
const origin = ctx.healthchecks[s.name]?.origin;
|
||||
let from = 'available evidence';
|
||||
if (origin === 'runtime') from = 'currently running containers';
|
||||
else if (origin === 'local-image') from = 'the locally available image';
|
||||
return {
|
||||
ruleId: 'no-healthcheck',
|
||||
severity: 'warning' as const,
|
||||
title: 'No effective healthcheck detected',
|
||||
message: `Service "${s.name}" has no effective healthcheck in ${from}, so Docker and Sencho cannot tell when it is actually ready.`,
|
||||
sourcePath: s.name,
|
||||
service: s.name,
|
||||
remediation: 'Add a healthcheck to the Compose service, or use an image that defines one.',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const healthcheckDisabled: PreflightRule = {
|
||||
id: 'healthcheck-disabled',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return ctx.model.services
|
||||
.filter(s => ctx.healthchecks[s.name]?.state === 'explicitly-disabled')
|
||||
.map(s => ({
|
||||
ruleId: 'no-healthcheck',
|
||||
ruleId: 'healthcheck-disabled',
|
||||
severity: 'warning' as const,
|
||||
title: 'No healthcheck',
|
||||
message: `Service "${s.name}" declares no healthcheck, so Docker and Sencho cannot tell when it is actually ready (the image may still define one).`,
|
||||
title: 'Healthcheck explicitly disabled',
|
||||
message: `Service "${s.name}" disables its healthcheck in the Compose model (disable: true or test: NONE), so Docker will not report readiness for this service.`,
|
||||
sourcePath: s.name,
|
||||
service: s.name,
|
||||
remediation: 'Add a healthcheck, or confirm the image provides one.',
|
||||
remediation: 'Remove the disablement, or replace it with an active healthcheck if the service should report readiness.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const healthcheckInherited: PreflightRule = {
|
||||
id: 'healthcheck-inherited',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return ctx.model.services
|
||||
.filter(s => {
|
||||
const state = ctx.healthchecks[s.name]?.state;
|
||||
return state === 'runtime-inherited' || state === 'local-image-inherited';
|
||||
})
|
||||
.map(s => {
|
||||
const origin = ctx.healthchecks[s.name]?.origin;
|
||||
const from = origin === 'runtime'
|
||||
? 'Docker is using the healthcheck from the currently running container image'
|
||||
: 'Docker is using the healthcheck defined by the locally available container image';
|
||||
return {
|
||||
ruleId: 'healthcheck-inherited',
|
||||
severity: 'info' as const,
|
||||
title: 'Healthcheck inherited from image',
|
||||
message: `Service "${s.name}" does not declare a healthcheck in Compose. ${from}.`,
|
||||
sourcePath: s.name,
|
||||
service: s.name,
|
||||
remediation: 'Optionally declare the healthcheck in Compose so its configuration remains explicit and independently controlled.',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const healthcheckUnverifiable: PreflightRule = {
|
||||
id: 'healthcheck-unverifiable',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return ctx.model.services
|
||||
.filter(s => ctx.healthchecks[s.name]?.state === 'unverifiable')
|
||||
.map(s => ({
|
||||
ruleId: 'healthcheck-unverifiable',
|
||||
severity: 'info' as const,
|
||||
title: 'Healthcheck inheritance could not be verified',
|
||||
message: `Service "${s.name}" has no Compose healthcheck, and Sencho could not verify whether the running container or a local image provides one (Docker unreachable, image missing locally, or incomplete inspection).`,
|
||||
sourcePath: s.name,
|
||||
service: s.name,
|
||||
remediation: 'Ensure the declared image is present locally, or add an explicit Compose healthcheck. Sencho does not pull images during Doctor runs.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const healthcheckInconsistent: PreflightRule = {
|
||||
id: 'healthcheck-inconsistent',
|
||||
run(ctx) {
|
||||
if (!ctx.model) return [];
|
||||
return ctx.model.services
|
||||
.filter(s => ctx.healthchecks[s.name]?.state === 'inconsistent-replicas')
|
||||
.map(s => ({
|
||||
ruleId: 'healthcheck-inconsistent',
|
||||
severity: 'warning' as const,
|
||||
title: 'Replica healthcheck coverage is inconsistent',
|
||||
message: `Service "${s.name}" has running replicas with mixed effective healthcheck coverage, so readiness is not uniform across replicas.`,
|
||||
sourcePath: s.name,
|
||||
service: s.name,
|
||||
remediation: 'Recreate the service so every replica uses the same image and healthcheck configuration.',
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -803,6 +902,10 @@ export const PREFLIGHT_RULES: PreflightRule[] = [
|
||||
imageLatest,
|
||||
noRestartPolicy,
|
||||
noHealthcheck,
|
||||
healthcheckDisabled,
|
||||
healthcheckInherited,
|
||||
healthcheckUnverifiable,
|
||||
healthcheckInconsistent,
|
||||
deploySwarmOnly,
|
||||
nodeStateUnavailable,
|
||||
externalNetworkMissing,
|
||||
|
||||
@@ -84,6 +84,34 @@ export interface BindCheck {
|
||||
ownerUid: number | null;
|
||||
}
|
||||
|
||||
/** Effective healthcheck coverage state for one Compose service. */
|
||||
export type HealthcheckEvidenceState =
|
||||
| 'compose-declared'
|
||||
| 'explicitly-disabled'
|
||||
| 'runtime-inherited'
|
||||
| 'local-image-inherited'
|
||||
| 'absent'
|
||||
| 'unverifiable'
|
||||
| 'inconsistent-replicas';
|
||||
|
||||
/** Which layer produced the decisive healthcheck evidence. */
|
||||
export type HealthcheckEvidenceOrigin =
|
||||
| 'compose'
|
||||
| 'runtime'
|
||||
| 'local-image'
|
||||
| 'none';
|
||||
|
||||
/**
|
||||
* Structural healthcheck evidence for one service. Never carries Test command
|
||||
* text (commands can include credentials or interpolated secrets).
|
||||
*/
|
||||
export interface ServiceHealthcheckEvidence {
|
||||
state: HealthcheckEvidenceState;
|
||||
origin: HealthcheckEvidenceOrigin;
|
||||
/** null when replica consistency does not apply (no runtime replicas inspected). */
|
||||
consistentReplicas: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the pure rule functions need, computed once by the service so the
|
||||
* rules stay synchronous and individually testable. No field ever holds an
|
||||
@@ -131,4 +159,9 @@ export interface PreflightContext {
|
||||
exposureAvailable: boolean;
|
||||
/** True when this stack is the running Sencho instance on the node. */
|
||||
isSelfStack: boolean;
|
||||
/**
|
||||
* Per-service effective healthcheck evidence (Compose, runtime, local image).
|
||||
* Empty when the model is null. Structural facts only; never Test command text.
|
||||
*/
|
||||
healthchecks: Record<string, ServiceHealthcheckEvidence>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,12 @@ export function parseImageRef(imageRef: string): ParsedRef | null {
|
||||
if (slashIdx !== -1) {
|
||||
const firstPart = imageRef.slice(0, slashIdx);
|
||||
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
|
||||
registry = firstPart;
|
||||
// docker.io / index.docker.io are Docker Hub aliases; normalize them to the
|
||||
// actual registry API host so requests never hit the marketing domain (which
|
||||
// redirects instead of serving /v2/) and the library/ auto-prefix below still applies.
|
||||
registry = (firstPart === 'docker.io' || firstPart === 'index.docker.io')
|
||||
? 'registry-1.docker.io'
|
||||
: firstPart;
|
||||
rest = imageRef.slice(slashIdx + 1);
|
||||
}
|
||||
}
|
||||
@@ -214,7 +219,12 @@ const MANIFEST_ACCEPT = [
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
].join(', ');
|
||||
|
||||
/** docker.io has three hostnames that all address the same registry. */
|
||||
/**
|
||||
* docker.io has three hostnames that all address the same registry. Folds two
|
||||
* already-parsed ParsedRef.registry values down to a shared form for equality/cache-key
|
||||
* comparisons; not the canonical form parseImageRef assigns (which normalizes to
|
||||
* 'registry-1.docker.io').
|
||||
*/
|
||||
function canonicalRegistry(host: string): string {
|
||||
if (host === 'docker.io' || host === 'index.docker.io' || host === 'registry-1.docker.io') {
|
||||
return 'docker.io';
|
||||
@@ -242,14 +252,16 @@ export function repoDigestMatchesRef(repoDigest: string, parsed: ParsedRef): boo
|
||||
const SHA256_DIGEST_RE = /^sha256:[0-9a-f]{64}$/i;
|
||||
|
||||
/**
|
||||
* Deterministic local RepoDigest selection shared by the scanner and the
|
||||
* update preview: the first entry whose repository matches the parsed
|
||||
* image ref, else the sole remaining valid entry, else null. A truncated
|
||||
* or malformed digest (not a complete "sha256:" + 64 hex chars) is never
|
||||
* selected, so a corrupted RepoDigests entry surfaces as "could not
|
||||
* resolve" rather than a false match or update.
|
||||
* All usable local RepoDigests for a parsed image ref, shared by the scanner
|
||||
* and update preview. Returns every valid digest whose repository matches the
|
||||
* ref (deduped, first-seen order), else []. A truncated or malformed digest is
|
||||
* never selected, and a valid digest belonging to an unrelated repository
|
||||
* (e.g. left over from a retag) is never selected either, so either surfaces
|
||||
* as "could not resolve" rather than a guessed match or update. Callers must
|
||||
* compare every candidate: Docker can list a stale index digest ahead of the
|
||||
* current one on the same image.
|
||||
*/
|
||||
export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef): string | null {
|
||||
export function selectLocalRepoDigests(repoDigests: readonly string[], parsed: ParsedRef): string[] {
|
||||
const valid = repoDigests
|
||||
.map((entry) => {
|
||||
const at = entry.indexOf('@');
|
||||
@@ -257,9 +269,28 @@ export function selectLocalRepoDigest(repoDigests: string[], parsed: ParsedRef):
|
||||
})
|
||||
.filter((e): e is { entry: string; digest: string } => e !== null && SHA256_DIGEST_RE.test(e.digest));
|
||||
|
||||
const matched = valid.find((e) => repoDigestMatchesRef(e.entry, parsed));
|
||||
if (matched) return matched.digest;
|
||||
return valid.length === 1 ? valid[0].digest : null;
|
||||
const matched: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of valid) {
|
||||
if (!repoDigestMatchesRef(e.entry, parsed)) continue;
|
||||
const key = e.digest.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
matched.push(e.digest);
|
||||
}
|
||||
// No digest matches the configured repository: comparing an unrelated
|
||||
// repository's digest (e.g. a retag) risks a false update against a
|
||||
// registry state that has nothing to do with the image actually
|
||||
// declared. Report unresolved instead of guessing.
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scalar view of {@link selectLocalRepoDigests}: the first candidate, or null.
|
||||
* Prefer the plural form when comparing against a remote tag.
|
||||
*/
|
||||
export function selectLocalRepoDigest(repoDigests: readonly string[], parsed: ParsedRef): string | null {
|
||||
return selectLocalRepoDigests(repoDigests, parsed)[0] ?? null;
|
||||
}
|
||||
|
||||
/** Outcome of a remote-digest lookup: the digest, or a human-readable reason it failed. */
|
||||
@@ -754,24 +785,41 @@ async function classifyManifest(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a local image digest to the registry's current manifest for a tag.
|
||||
* `platform` is the local image's Os/Architecture (from `docker image inspect`),
|
||||
* required to safely match against an index's platform descriptors; without it,
|
||||
* an index mismatch is an error rather than a speculative match. Never retries
|
||||
* against the mutable tag once a primary digest is established: classification
|
||||
* always targets that digest.
|
||||
* Compare local image digests to the registry's current manifest for a tag.
|
||||
* Any candidate that equals the remote primary or is a member of that primary's
|
||||
* index counts as current (Docker often lists a stale index digest ahead of the
|
||||
* current one). `platform` is the local image's Os/Architecture (from
|
||||
* `docker image inspect`), required to safely match against an index's platform
|
||||
* descriptors; without it, an index mismatch is an error rather than a
|
||||
* speculative match. Never retries against the mutable tag once a primary
|
||||
* digest is established: classification always targets that digest.
|
||||
*
|
||||
* `update` is returned only after a successful, complete remote classification
|
||||
* with no candidate matching the primary, an exact member, or a same-platform
|
||||
* runnable descriptor that the index actually offers. When the index has no
|
||||
* descriptor labeled for the local platform, a platform-less leaf (which OCI
|
||||
* permits) is trusted as this platform's content only when it is the ONLY
|
||||
* kind of descriptor present (nothing else in the index claims a different
|
||||
* platform); an index that mixes platform-less leaves with descriptors
|
||||
* labeled for other platforms cannot attribute the unlabeled ones and errors
|
||||
* instead. Empty/all-malformed candidates, unknown platform when platform
|
||||
* matching is required, an index with no runnable content for the local
|
||||
* platform at all (including an empty or fully-filtered index), and
|
||||
* classification failures also return `error`.
|
||||
*/
|
||||
export async function compareLocalToRemoteTag(
|
||||
localDigest: string,
|
||||
localDigests: readonly string[],
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string,
|
||||
platform: { os: string; architecture: string },
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<DigestComparisonResult> {
|
||||
if (!SHA256_DIGEST_RE.test(localDigest)) {
|
||||
const candidates = localDigests.filter((d) => SHA256_DIGEST_RE.test(d));
|
||||
if (candidates.length === 0) {
|
||||
return { kind: 'error', reason: 'Local digest is malformed or truncated' };
|
||||
}
|
||||
const candidateSet = new Set(candidates.map((d) => d.toLowerCase()));
|
||||
|
||||
const ref = `${registry}/${repo}:${tag}`;
|
||||
const probe = await probeManifestForRef(registry, repo, tag, credentials, ref);
|
||||
@@ -781,7 +829,7 @@ export async function compareLocalToRemoteTag(
|
||||
if (!SHA256_DIGEST_RE.test(primaryDigest)) {
|
||||
return { kind: 'error', reason: `Registry returned a malformed digest for ${ref}` };
|
||||
}
|
||||
if (localDigest === primaryDigest) return { kind: 'match' };
|
||||
if (candidateSet.has(primaryDigest.toLowerCase())) return { kind: 'match' };
|
||||
|
||||
let classification: ManifestClassification;
|
||||
try {
|
||||
@@ -792,15 +840,35 @@ export async function compareLocalToRemoteTag(
|
||||
|
||||
if (classification.kind === 'single') return { kind: 'update' };
|
||||
|
||||
if (classification.exactDigests.includes(localDigest)) return { kind: 'match' };
|
||||
if (classification.exactDigests.some((d) => candidateSet.has(d.toLowerCase()))) return { kind: 'match' };
|
||||
|
||||
if (!platform.os || !platform.architecture) {
|
||||
return { kind: 'error', reason: `Local image platform is unknown; cannot verify multi-arch membership for ${ref}` };
|
||||
}
|
||||
|
||||
const isMember = classification.descriptors.some(
|
||||
(d) => d.os === platform.os && d.architecture === platform.architecture && d.digest === localDigest,
|
||||
const platformDescriptors = classification.descriptors.filter(
|
||||
(d) => d.os === platform.os && d.architecture === platform.architecture,
|
||||
);
|
||||
if (platformDescriptors.length === 0) {
|
||||
// No descriptor is labeled for this platform. exactDigests leaves omit
|
||||
// platform entirely (OCI allows this), so they cannot be attributed to
|
||||
// any specific platform; their mere presence does not prove content
|
||||
// for THIS one, especially when other descriptors in the same index
|
||||
// are explicitly labeled for a different platform. Only when every
|
||||
// descriptor in the index is unlabeled (classification.descriptors is
|
||||
// empty) does a leaf's presence plausibly represent this platform's
|
||||
// content, since nothing else claims a different one; that is the one
|
||||
// case where reporting `update` instead of failing closed is safe.
|
||||
if (classification.exactDigests.length === 0) {
|
||||
return { kind: 'error', reason: `Remote image index has no ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
}
|
||||
if (classification.descriptors.length > 0) {
|
||||
return { kind: 'error', reason: `Remote image index has no confirmed ${platform.os}/${platform.architecture} variant for ${ref}` };
|
||||
}
|
||||
return { kind: 'update' };
|
||||
}
|
||||
|
||||
const isMember = platformDescriptors.some((d) => candidateSet.has(d.digest.toLowerCase()));
|
||||
return isMember ? { kind: 'match' } : { kind: 'update' };
|
||||
}
|
||||
|
||||
@@ -852,20 +920,30 @@ function parseNextCursor(linkHeader: string | string[] | undefined): string | un
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed tag list for the Resources registry browser. Never collapses auth
|
||||
* failures into an empty array (that would hide credential problems).
|
||||
* Typed tag list for the Resources registry browser and update-preview authority.
|
||||
* Never collapses auth failures into an empty array (that would hide credential
|
||||
* problems and falsely look like a successful empty listing). `credentials` is
|
||||
* optional: `getAuthToken` already resolves an anonymous pull token for public
|
||||
* repositories on registries whose `WWW-Authenticate` challenge grants one
|
||||
* without credentials (Docker Hub unconditionally; others via the standard
|
||||
* token-service challenge), so a public repository still returns a real tag
|
||||
* list with none configured.
|
||||
*/
|
||||
export async function listRegistryTagsResult(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials: RegistryCredentials,
|
||||
credentials?: RegistryCredentials | null,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<TagListResult> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 100);
|
||||
try {
|
||||
const token = await getAuthToken(registry, repo, credentials);
|
||||
if (!token) {
|
||||
return { ok: false, code: 'REGISTRY_UNAUTHORIZED', message: 'Registry rejected credentials' };
|
||||
return {
|
||||
ok: false,
|
||||
code: 'REGISTRY_UNAUTHORIZED',
|
||||
message: credentials ? 'Registry rejected credentials' : 'Registry did not issue an anonymous token for this repository',
|
||||
};
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: 'application/json', Authorization: `Bearer ${token}` };
|
||||
const params = new URLSearchParams({ n: String(limit) });
|
||||
@@ -896,13 +974,12 @@ export async function listRegistryTagsResult(
|
||||
}
|
||||
}
|
||||
|
||||
/** Compatibility wrapper for update-preview: empty list on any failure. */
|
||||
/** Compatibility wrapper for callers that only need tags: empty list on any failure. */
|
||||
export async function listRegistryTags(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: RegistryCredentials | null,
|
||||
): Promise<string[]> {
|
||||
if (!credentials) return [];
|
||||
const result = await listRegistryTagsResult(registry, repo, credentials);
|
||||
return result.ok ? result.tags : [];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PreflightStatus } from '../preflight/types';
|
||||
import type { UpdatePreviewSummary } from '../UpdatePreviewService';
|
||||
import type { UpdatePreviewImage, UpdatePreviewSummary } from '../UpdatePreviewService';
|
||||
import type {
|
||||
ContainerProbe,
|
||||
ReadinessSignal,
|
||||
@@ -109,11 +109,39 @@ export function healthchecksSignal(input: ContainerProbe[] | Errored): Readiness
|
||||
return { ...base, detail: parts.join(' ') };
|
||||
}
|
||||
|
||||
export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): ReadinessSignal {
|
||||
/**
|
||||
* True when a full-stack apply would pull/recreate an image whose digest
|
||||
* verification failed as collateral of applying a DIFFERENT image's confirmed
|
||||
* update or a local rebuild. `has_update` and `check_error` are independent
|
||||
* per image (a tag-based update can be confirmed via the registry's tag list
|
||||
* even when that same image's own digest comparison errored), so this
|
||||
* excludes the case where the only verification failure belongs to the very
|
||||
* image whose update is confirmed: that image's own stale-digest check does
|
||||
* not block moving it to a newer tag. Falls back to the aggregate summary
|
||||
* flags when per-image detail is unavailable.
|
||||
*/
|
||||
function hasUnverifiedOtherImage(summary: UpdatePreviewSummary, images: UpdatePreviewImage[] | undefined): boolean {
|
||||
if (!images || images.length === 0) {
|
||||
return summary.verification_failed && (summary.has_update || summary.rebuild_available);
|
||||
}
|
||||
const hasPureFailureImage = images.some((i) => Boolean(i.check_error) && !i.has_update);
|
||||
if (!hasPureFailureImage) return false;
|
||||
return images.some((i) => i.has_update) || summary.rebuild_available;
|
||||
}
|
||||
|
||||
export function updatePreviewSignal(input: UpdatePreviewSummary | Errored, images?: UpdatePreviewImage[]): ReadinessSignal {
|
||||
const base = { id: 'update_preview' as const, title: 'Pending update' };
|
||||
if (input === 'error') {
|
||||
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The update preview is unavailable.' };
|
||||
}
|
||||
if (input.detection_disabled) {
|
||||
return {
|
||||
...base,
|
||||
status: 'unknown',
|
||||
affectsVerdict: false,
|
||||
detail: 'Image update detection is disabled for this node.',
|
||||
};
|
||||
}
|
||||
if (input.blocked) {
|
||||
return {
|
||||
...base,
|
||||
@@ -129,20 +157,50 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored): Read
|
||||
if (input.has_update && input.semver_bump === 'unknown') {
|
||||
return { ...base, status: 'warning', affectsVerdict: true, detail: 'An image update is pending but the version change could not be classified.' };
|
||||
}
|
||||
// A DIFFERENT image in the stack failing digest verification holds any
|
||||
// pending action (a tag/digest update or a local-build rebuild) for review,
|
||||
// since a full-stack apply would pull/recreate the unverified image as
|
||||
// collateral. has_update and rebuild_available are handled symmetrically
|
||||
// here to match isReviewRequiredUpdatePreview on the frontend.
|
||||
const reviewRequired = hasUnverifiedOtherImage(input, images);
|
||||
if (input.has_update) {
|
||||
const kind = input.update_kind === 'digest' ? 'a same-tag image refresh' : `a ${input.semver_bump} update`;
|
||||
const buildNote = input.has_build_services
|
||||
? ' Local build services will also be rebuilt from source.'
|
||||
: '';
|
||||
if (reviewRequired) {
|
||||
const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.';
|
||||
return {
|
||||
...base,
|
||||
status: 'attention',
|
||||
affectsVerdict: true,
|
||||
detail: `Pending: ${kind}.${buildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: `Pending: ${kind}.${buildNote}` };
|
||||
}
|
||||
if (input.rebuild_available) {
|
||||
const n = input.has_build_services ? 'Local build service(s)' : 'Build';
|
||||
const rebuildNote = `${n} require a rebuild from source; the update rebuilds images and recreates containers.`;
|
||||
if (reviewRequired) {
|
||||
const verifyNote = input.verification_error ? `: ${input.verification_error}` : '.';
|
||||
return {
|
||||
...base,
|
||||
status: 'attention',
|
||||
affectsVerdict: true,
|
||||
detail: `${rebuildNote} Another image failed digest verification${verifyNote} Review before a full-stack update.`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'warning', affectsVerdict: true, detail: rebuildNote };
|
||||
}
|
||||
if (input.verification_failed) {
|
||||
return {
|
||||
...base,
|
||||
status: 'warning',
|
||||
status: 'unknown',
|
||||
affectsVerdict: true,
|
||||
detail: `${n} require a rebuild from source; the update rebuilds images and recreates containers.`,
|
||||
detail: input.verification_error
|
||||
? `Digest verification failed: ${input.verification_error}`
|
||||
: 'Digest verification failed; Sencho is not claiming a rebuild.',
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'ok', affectsVerdict: true, detail: 'No pending image update detected; the update re-pulls and recreates with current tags.' };
|
||||
|
||||
@@ -19,6 +19,8 @@ declare global {
|
||||
mfaPendingUserId?: number;
|
||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||
mfaPendingSso?: boolean;
|
||||
/** True when the caller's current user-session cookie was issued with "stay signed in". Read by reissueSessionAfterTokenBump so a password/MFA change doesn't silently shorten a remembered session. */
|
||||
sessionRemember?: boolean;
|
||||
/** Cached remote-proxy target resolved by `remoteNodeProxy`'s outer gate so the http-proxy router/proxyReq callbacks do not re-resolve. */
|
||||
proxyTarget?: { apiUrl: string; apiToken: string };
|
||||
/** Trusted deploy provenance from machine auth or gateway overwrite. */
|
||||
|
||||
@@ -42,6 +42,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /system/networks/delete': 'Deleted networks',
|
||||
'POST /system/networks': 'Created network',
|
||||
'POST /system/console-token': 'Generated console token',
|
||||
'POST /system/reapply-compose': 'Triggered compose reapply',
|
||||
|
||||
// Node management
|
||||
'POST /nodes': 'Added node',
|
||||
@@ -91,6 +92,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'DELETE /fleet/snapshots': 'Deleted fleet backup',
|
||||
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
|
||||
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
|
||||
'POST /fleet/nodes/*/reapply-compose': 'Triggered fleet node compose reapply',
|
||||
'POST /fleet/update-all': 'Triggered fleet-wide update',
|
||||
'POST /fleet/role/reanchor': 'Re-anchored fleet replica',
|
||||
'POST /fleet/role/demote': 'Demoted fleet replica to control',
|
||||
@@ -106,6 +108,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'PUT /sso/config': 'Updated SSO configuration',
|
||||
'DELETE /sso/config': 'Deleted SSO configuration',
|
||||
'POST /sso/config/*/test': 'Tested SSO configuration',
|
||||
'PUT /sso/auth-mode': 'Updated authentication mode',
|
||||
|
||||
// API tokens
|
||||
'POST /api-tokens': 'Created API token',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user