From 73f95ac8e1a87d7fc58553814c757bdbad6d7484 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 10 Jun 2026 09:47:19 +0000 Subject: [PATCH 01/23] feat: complete custom CSS support for status pages The customCSS field existed on the model, types, and form schema but was never editable, sent to the API, or rendered. Wire it end to end: - Accept customCSS in the create/update status page body validation and regenerate the OpenAPI spec - Add a Custom CSS editor (multiline field) to the status page form and include the value in the submitted form data - Support multiline mode in the shared TextInput component - Inject the saved CSS via a style tag when rendering the status page --- client/src/Components/inputs/TextInput.tsx | 2 +- client/src/Pages/StatusPage/Create/index.tsx | 28 +++++++++++++++++++ .../Status/themes/shared/BaseStatusPage.tsx | 1 + client/src/locales/en.json | 10 +++++++ server/openapi.json | 6 ++++ .../api/validation/statusPageValidation.ts | 1 + 6 files changed, 47 insertions(+), 1 deletion(-) diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 13277be72..230e7650b 100644 --- a/client/src/Components/inputs/TextInput.tsx +++ b/client/src/Components/inputs/TextInput.tsx @@ -22,7 +22,7 @@ export const TextInput = forwardRef(function T width: "100%", "& .MuiOutlinedInput-root": { borderRadius: theme.shape.borderRadius, - height: 34, + height: props.multiline ? "auto" : 34, fontSize: typographyLevels.base, overflow: "hidden", }, diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index c9bc04838..921ca8bdb 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -157,6 +157,7 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + fd.append("customCSS", data.customCSS ?? ""); if (data.theme) fd.append("theme", data.theme); if (data.themeMode) fd.append("themeMode", data.themeMode); @@ -504,6 +505,33 @@ const CreateStatusPage = () => { } /> )} + {showStep(1) && ( + ( + + )} + /> + } + /> + )} {showStep(1) && ( { return ( + {statusPage.customCSS && } Date: Wed, 10 Jun 2026 10:02:39 +0000 Subject: [PATCH 02/23] feat: cap custom CSS length and add validation tests Bound customCSS to 100000 characters on both the client schema and the server body validation (the server is the enforcement boundary; multer's 1MB field cap was the only limit), regenerate the OpenAPI spec, and add a validation test suite covering acceptance, clearing, the length cap, and type rejection. --- client/src/Validation/statusPage.ts | 6 ++- server/openapi.json | 6 ++- .../api/validation/statusPageValidation.ts | 2 +- .../validation/statusPageValidation.test.ts | 51 +++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 server/test/unit/validation/statusPageValidation.test.ts diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 542f6b2ae..3533fe017 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -35,7 +35,11 @@ export const statusPageSchema = z.object({ showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), - customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .optional() + .register(statusPageStepRegistry, { step: 1 }), theme: z .enum(STATUS_PAGE_THEMES) .optional() diff --git a/server/openapi.json b/server/openapi.json index 838c94863..ae34fba04 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10488,7 +10488,8 @@ "type": "boolean" }, "customCSS": { - "type": "string" + "type": "string", + "maxLength": 100000 }, "removeLogo": { "anyOf": [ @@ -10694,7 +10695,8 @@ "type": "boolean" }, "customCSS": { - "type": "string" + "type": "string", + "maxLength": 100000 }, "removeLogo": { "anyOf": [ diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 3634d831f..c27ca3f15 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -32,7 +32,7 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), - customCSS: z.string().optional(), + customCSS: z.string().max(100000, "Custom CSS must be at most 100000 characters").optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts new file mode 100644 index 000000000..bc67efc42 --- /dev/null +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@jest/globals"; +import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; + +const baseCreateBody = (overrides: Record = {}) => ({ + type: "uptime", + companyName: "Test Co", + url: "my-status-page", + monitors: ["0123456789abcdef01234567"], + isPublished: true, + showUptimePercentage: true, + ...overrides, +}); + +describe("createStatusPageBodyValidation", () => { + it("accepts a valid body without customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts and preserves customCSS", () => { + const customCSS = ".status-page > footer { display: none; }"; + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(customCSS); + } + }); + + it("accepts an empty customCSS so it can be cleared", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "" })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(""); + } + }); + + it("rejects customCSS over the maximum length", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "a".repeat(100001) })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "Custom CSS must be at most 100000 characters", path: ["customCSS"] })]) + ); + } + }); + + it("rejects a non-string customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); + expect(result.success).toBe(false); + }); +}); From 25d883944147a2d145004b208dded18a6a1a4ed7 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 10 Jun 2026 13:43:15 +0000 Subject: [PATCH 03/23] feat: render admin status page preview in an iframe The preview previously rendered the themed status page inline in the admin document, so document-global concerns leaked in both directions: custom CSS spilled into the admin UI and the theme body painting never showed. Pointing the BrowserFrame at the real public URL in an iframe isolates the CSS completely and makes the preview pixel-identical to what visitors see. Auth for unpublished pages keeps working because the same-origin iframe shares localStorage and the API client reads the token from the rehydrated store. --- client/src/Pages/StatusPage/Status/index.tsx | 28 +++++++++++-------- .../StatusPage/Status/themes/BrowserFrame.tsx | 8 +++++- client/src/locales/en.json | 3 ++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 9c4001fae..f2ac1a83e 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; import { Link } from "react-router-dom"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -111,17 +112,9 @@ const StatusPageView = () => { ); } - const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; - const themedRenderer = ( - - ); - // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; return ( { timezone={statusPage.timezone} paintBody > - {themedRenderer} + ); } @@ -153,7 +150,16 @@ const StatusPageView = () => { timezone={statusPage.timezone} transparent > - {themedRenderer} + + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx index b893aa85c..183e1fdea 100644 --- a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => { {url} - {children} + + {children} + ); }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index a142156b2..b2a643f8b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1606,6 +1606,9 @@ "header": { "title": "Status pages", "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." + }, + "preview": { + "title": "Status page preview" } }, "uptime": { From 69e142f71180fe03627bbbdcf20d992e95368732 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Fri, 12 Jun 2026 15:43:58 +0000 Subject: [PATCH 04/23] fix: restrict status page mutations to admins and superadmins Creating, updating and deleting status pages was only guarded by verifyJWT, so any authenticated team member could set fields including the new customCSS via a direct API call. The admin gating existed only in the client UI (hidden buttons), and the create/configure routes had no guard, so the form was reachable by URL. Enforce the role at the API with isAllowed, matching the monitor routes. --- server/src/api/routes/statusPageRoute.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index 807970e4b..db195b6f9 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,5 +1,6 @@ import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import multer from "multer"; const upload = multer(); @@ -16,11 +17,11 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", upload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", upload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); - this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); + this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage); } getRouter() { From 3685e0c47642eb7388903a59d05153b503893a5a Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Mon, 22 Jun 2026 22:53:47 +0000 Subject: [PATCH 05/23] feat: reject custom CSS that references external resources Reject status page custom CSS containing @import or a url() / image-set() / image() / cross-fade() whose target is http(s) or protocol-relative, while allowing data: and relative URLs. The check normalizes comments and CSS escapes first so obfuscated forms cannot slip past. Enforced in the create/update body validation. --- .../api/validation/statusPageValidation.ts | 9 ++- server/src/utils/customCss.ts | 23 ++++++++ server/test/unit/utils/customCss.test.ts | 58 +++++++++++++++++++ .../validation/statusPageValidation.test.ts | 12 ++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 server/src/utils/customCss.ts create mode 100644 server/test/unit/utils/customCss.test.ts diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index cb2ab4ed9..a650129cd 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; +import { cssReferencesExternalResource } from "@/utils/customCss.js"; //**************************************** // Status Page Validations @@ -48,7 +49,13 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), - customCSS: z.string().max(100000, "Custom CSS must be at most 100000 characters").optional(), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts new file mode 100644 index 000000000..0705efe46 --- /dev/null +++ b/server/src/utils/customCss.ts @@ -0,0 +1,23 @@ +// Normalize the CSS the way a browser does before matching, so obfuscated forms +// (inline comments, CSS escapes like "\75 rl(") can't hide an external reference. +const decodeForScan = (css: string): string => + css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\\([0-9a-fA-F]{1,6})\s?|\\([\s\S])/g, (_match, hex: string, char: string) => { + if (!hex) { + return char; + } + const codePoint = parseInt(hex, 16); + return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "�"; + }); + +// An external reference is @import, or a resource function (url, image-set, +// image, cross-fade) whose target starts with an http(s) scheme or is +// protocol-relative. Anchoring on the start of the target keeps data: and +// relative URLs allowed, including data: SVG that embeds the SVG namespace URL. +const EXTERNAL_REFERENCE = /(?:@import\b|(?:url|image-set|-webkit-image-set|image|cross-fade|-webkit-cross-fade)\s*\(\s*['"]?\s*(?:https?:|\/\/))/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + return EXTERNAL_REFERENCE.test(decodeForScan(css)); +}; diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts new file mode 100644 index 000000000..1efdb8012 --- /dev/null +++ b/server/test/unit/utils/customCss.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@jest/globals"; +import { cssReferencesExternalResource } from "../../../src/utils/customCss.ts"; + +describe("cssReferencesExternalResource", () => { + it("returns false for empty or non-string input", () => { + expect(cssReferencesExternalResource("")).toBe(false); + expect(cssReferencesExternalResource(null)).toBe(false); + expect(cssReferencesExternalResource(undefined)).toBe(false); + }); + + it("allows benign CSS", () => { + expect(cssReferencesExternalResource("li{outline:3px dashed red}footer{display:none}")).toBe(false); + }); + + it("allows data: and relative urls", () => { + expect(cssReferencesExternalResource("li{background:url(data:image/png;base64,AAAA)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(/assets/logo.png)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(logo.png)}")).toBe(false); + }); + + it("allows data: SVG that embeds the SVG namespace URL", () => { + const css = `li{background:url("data:image/svg+xml,")}`; + expect(cssReferencesExternalResource(css)).toBe(false); + }); + + it("flags @import", () => { + expect(cssReferencesExternalResource('@import url("https://evil.example/x.css");')).toBe(true); + expect(cssReferencesExternalResource('@import "https://evil.example/x.css";')).toBe(true); + }); + + it("flags external url()", () => { + expect(cssReferencesExternalResource('body{background:url("https://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x)}")).toBe(true); + }); + + it("flags protocol-relative url()", () => { + expect(cssReferencesExternalResource("body{background:url(//evil.example/x)}")).toBe(true); + }); + + it("flags external string-form image functions", () => { + expect(cssReferencesExternalResource('body{background:image-set("https://evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background:-webkit-image-set("//evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background-image:image("https://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with a trailing parse error", () => { + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); + }); + + it("flags url() obfuscated with an inline comment", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with CSS escapes", () => { + expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); + expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index bc67efc42..a2d659c34 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -48,4 +48,16 @@ describe("createStatusPageBodyValidation", () => { const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); expect(result.success).toBe(false); }); + + it("rejects customCSS that references an external resource", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 'body{background:url("https://evil.example/beacon")}' })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: "Custom CSS cannot reference external URLs or use @import", path: ["customCSS"] }), + ]) + ); + } + }); }); From 96021b747903e183440f92624c774c3047ddfd36 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Tue, 23 Jun 2026 19:25:18 +0000 Subject: [PATCH 06/23] refactor: detect external CSS references with the CSS Syntax tokenizer Replace the regex/escape-normalizer with @csstools/css-tokenizer (already present transitively). Tokenizing resolves comments, hex escapes, and line continuations into the real value before scanning, so obfuscated forms (a scheme split by a line continuation or hex escape) cannot slip past. Scan url-tokens and string-tokens to cover url(), quoted @import, and string-form image functions. Add regression tests for the bypasses. --- server/package-lock.json | 1 + server/package.json | 1 + server/src/utils/customCss.ts | 23 +++++++---------------- server/test/unit/utils/customCss.test.ts | 19 +++++++++++++++++-- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 0f16e6b5d..5498a5549 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", diff --git a/server/package.json b/server/package.json index 2ac0d47f9..8838e2c06 100755 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "node": ">=20" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "@pulsecron/pulse": "1.6.8", diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts index 0705efe46..3f38a0bd5 100644 --- a/server/src/utils/customCss.ts +++ b/server/src/utils/customCss.ts @@ -1,23 +1,14 @@ -// Normalize the CSS the way a browser does before matching, so obfuscated forms -// (inline comments, CSS escapes like "\75 rl(") can't hide an external reference. -const decodeForScan = (css: string): string => - css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\\([0-9a-fA-F]{1,6})\s?|\\([\s\S])/g, (_match, hex: string, char: string) => { - if (!hex) { - return char; - } - const codePoint = parseInt(hex, 16); - return codePoint > 0 && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : "�"; - }); +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; -// An external reference is @import, or a resource function (url, image-set, -// image, cross-fade) whose target starts with an http(s) scheme or is -// protocol-relative. Anchoring on the start of the target keeps data: and -// relative URLs allowed, including data: SVG that embeds the SVG namespace URL. -const EXTERNAL_REFERENCE = /(?:@import\b|(?:url|image-set|-webkit-image-set|image|cross-fade|-webkit-cross-fade)\s*\(\s*['"]?\s*(?:https?:|\/\/))/i; +// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; +// Tokenizing resolves comments, escapes, and line continuations so obfuscated +// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set(). export const cssReferencesExternalResource = (css?: string | null): boolean => { if (typeof css !== "string" || css === "") { return false; } - return EXTERNAL_REFERENCE.test(decodeForScan(css)); + + return tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)); }; diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts index 1efdb8012..bbdac7118 100644 --- a/server/test/unit/utils/customCss.test.ts +++ b/server/test/unit/utils/customCss.test.ts @@ -47,12 +47,27 @@ describe("cssReferencesExternalResource", () => { expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); }); - it("flags url() obfuscated with an inline comment", () => { - expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(true); + it("flags references obfuscated with inline comments", () => { + expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true); + expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true); + }); + + it("allows a comment that makes url() a bad token the browser never fetches", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false); }); it("flags url() obfuscated with CSS escapes", () => { expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); }); + + it("flags a scheme split by a line continuation", () => { + expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true); + }); + + it("flags a scheme split by a hex escape", () => { + expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true); + }); }); From ffbf2232882d30e53b76d1f6dfff0e84864069b1 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Tue, 23 Jun 2026 19:25:40 +0000 Subject: [PATCH 07/23] feat: tighten CSP on the public status page document Layer a stricter Content-Security-Policy on the /status/public document so custom CSS cannot load external images, fonts, or stylesheets. Browsers enforce the intersection of all CSP headers, so this only narrows the public status page and never loosens the global policy, while keeping the app's Google Fonts. Browser-enforced backstop behind the validator. --- .../api/middleware/statusPageDocumentCsp.ts | 19 +++++++++++ server/src/app.ts | 2 ++ .../middleware/statusPageDocumentCsp.test.ts | 34 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 server/src/api/middleware/statusPageDocumentCsp.ts create mode 100644 server/test/unit/middleware/statusPageDocumentCsp.test.ts diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 000000000..979f78791 --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,19 @@ +import type { NextFunction, Request, Response } from "express"; + +const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; + +// Browsers enforce the intersection of all CSP headers, so this only tightens the +// public status page on top of the global helmet policy: it blocks external images, +// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. +const STATUS_PAGE_CSP = [ + "img-src 'self' data:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); +}; diff --git a/server/src/app.ts b/server/src/app.ts index 907c0f77d..b79f71250 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,6 +14,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; +import { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -70,6 +71,7 @@ export const createApp = ({ }, }) ); + app.use(statusPageDocumentCsp); app.use( compression({ level: 6, diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 000000000..450728bfc --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"]; + return res; +}; + +describe("statusPageDocumentCsp", () => { + it("appends a tightened CSP on the public status page document", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next); + + expect(res.append).toHaveBeenCalledTimes(1); + const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string]; + expect(header).toBe("Content-Security-Policy"); + expect(value).toContain("img-src 'self' data:"); + expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); + expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves other routes untouched", () => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + statusPageDocumentCsp({ path: "/status/create" } as Request, res, next); + + expect(res.append).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); From a4cc322a55b1b6cc59e0e5b3b81aff636448b758 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:16 +0000 Subject: [PATCH 08/23] feat: apply status page CSP to custom domains Detect custom domains by comparing the request host to the app host and apply the tightened CSP there too, not just on the public path. Run the middleware before express.static so static-served root documents get the header, and keep helmet after static so its default-src does not block custom domain API calls. --- .../api/middleware/statusPageDocumentCsp.ts | 22 +++++++--- server/src/app.ts | 5 ++- .../middleware/statusPageDocumentCsp.test.ts | 44 ++++++++++++++----- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts index 979f78791..c5175b81d 100644 --- a/server/src/api/middleware/statusPageDocumentCsp.ts +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -1,9 +1,10 @@ import type { NextFunction, Request, Response } from "express"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; -// Browsers enforce the intersection of all CSP headers, so this only tightens the -// public status page on top of the global helmet policy: it blocks external images, +// Browsers enforce the intersection of all CSP headers, so this only tightens +// the status page on top of the global helmet policy, blocking external images, // fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. const STATUS_PAGE_CSP = [ "img-src 'self' data:", @@ -11,9 +12,16 @@ const STATUS_PAGE_CSP = [ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", ].join("; "); -export const statusPageDocumentCsp = (req: Request, res: Response, next: NextFunction) => { - if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX)) { - res.append("Content-Security-Policy", STATUS_PAGE_CSP); - } - return next(); +// A status page document is served on the public path or on a custom domain +// (any host other than the app's own). Mirrors the client isCustomDomainHost. +export const createStatusPageDocumentCsp = (clientHost: string) => { + const appHostname = normalizeStatusPageDomain(clientHost); + + return (req: Request, res: Response, next: NextFunction) => { + const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname; + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); + }; }; diff --git a/server/src/app.ts b/server/src/app.ts index b79f71250..6c9ba03bc 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -14,7 +14,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; -import { statusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; +import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; export const createApp = ({ services, @@ -49,6 +49,8 @@ export const createApp = ({ return cors(corsOptions)(req, res, next); }); + app.use(createStatusPageDocumentCsp(allowedOrigin)); + app.use(express.static(frontendPath)); app.use(express.json()); @@ -71,7 +73,6 @@ export const createApp = ({ }, }) ); - app.use(statusPageDocumentCsp); app.use( compression({ level: 6, diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts index 450728bfc..ab0c20abd 100644 --- a/server/test/unit/middleware/statusPageDocumentCsp.test.ts +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; import type { NextFunction, Request, Response } from "express"; -import { statusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; +import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; const makeRes = (): Response => { const res = {} as Response; @@ -8,25 +8,47 @@ const makeRes = (): Response => { return res; }; +const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001"); + +const run = (req: Partial) => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + cspMiddleware(req as Request, res, next); + return { res, next }; +}; + +const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1]; + describe("statusPageDocumentCsp", () => { - it("appends a tightened CSP on the public status page document", () => { - const res = makeRes(); - const next = jest.fn() as unknown as NextFunction; - statusPageDocumentCsp({ path: "/status/public/my-status-page" } as Request, res, next); + it("appends a tightened CSP on the public status page path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); expect(res.append).toHaveBeenCalledTimes(1); - const [header, value] = (res.append as jest.Mock).mock.calls[0] as [string, string]; - expect(header).toBe("Content-Security-Policy"); + const value = appendedValue(res); expect(value).toContain("img-src 'self' data:"); expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); expect(next).toHaveBeenCalledTimes(1); }); - it("leaves other routes untouched", () => { - const res = makeRes(); - const next = jest.fn() as unknown as NextFunction; - statusPageDocumentCsp({ path: "/status/create" } as Request, res, next); + it("does not restrict default-src or connect-src so the page can still reach the API", () => { + const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + const value = appendedValue(res); + expect(value).not.toContain("default-src"); + expect(value).not.toContain("connect-src"); + }); + + it("appends the CSP on a custom domain document at the host root", () => { + const { res, next } = run({ path: "/", hostname: "status.example.com" }); + + expect(res.append).toHaveBeenCalledTimes(1); + expect(appendedValue(res)).toContain("img-src 'self' data:"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves the app's own non-public routes untouched", () => { + const { res, next } = run({ path: "/status/create", hostname: "localhost" }); expect(res.append).not.toHaveBeenCalled(); expect(next).toHaveBeenCalledTimes(1); From ea23610b3a183bf43a7ecbd6bd03e2de48e7052f Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:27 +0000 Subject: [PATCH 09/23] fix: mirror server custom CSS validation on the client The form only enforced the length cap, so CSS with an external url() or @import passed client validation and was rejected by the server. Add the same tokenizer based check with the same error message so it is caught inline before submit. --- client/package-lock.json | 20 ++++++++++++++++++++ client/package.json | 1 + client/src/Utils/customCss.ts | 16 ++++++++++++++++ client/src/Validation/statusPage.ts | 4 ++++ 4 files changed, 41 insertions(+) create mode 100644 client/src/Utils/customCss.ts diff --git a/client/package-lock.json b/client/package-lock.json index c13d482de..9d565620a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "3.8.1", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", @@ -303,6 +304,25 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index 6e9844b28..6b22084db 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "**/*": "prettier --write --ignore-unknown" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", diff --git a/client/src/Utils/customCss.ts b/client/src/Utils/customCss.ts new file mode 100644 index 000000000..02a267daa --- /dev/null +++ b/client/src/Utils/customCss.ts @@ -0,0 +1,16 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects +// the same CSS the API would. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some( + (token) => + (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value) + ); +}; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 6d4e2fd6e..eddde8ab7 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; +import { cssReferencesExternalResource } from "@/Utils/customCss"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -61,6 +62,9 @@ export const statusPageSchema = z.object({ customCSS: z .string() .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) .optional() .register(statusPageStepRegistry, { step: 1 }), theme: z From 52b95ad1210e608755142b9ef3abeb6e149fd044 Mon Sep 17 00:00:00 2001 From: Buco7854 Date: Wed, 24 Jun 2026 18:21:40 +0000 Subject: [PATCH 10/23] fix: keep the admin status page preview iframe same-origin The preview framed the page public URL, which for a custom domain is cross origin and blocked by the admin document CSP frame-src. Point the iframe at the same-origin public path while the frame chrome still shows the public URL. --- client/src/Pages/StatusPage/Status/index.tsx | 4 +++- client/src/Utils/statusPageUrl.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 6aed8817b..834228843 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -17,6 +17,7 @@ import { } from "@/Types/StatusPage"; import { buildStatusPageApiPath, + getStatusPagePreviewUrl, getStatusPagePublicUrl, isCustomDomainHost, } from "@/Utils/statusPageUrl"; @@ -142,6 +143,7 @@ const StatusPageView = () => { } const publicUrl = getStatusPagePublicUrl(statusPage); + const previewUrl = getStatusPagePreviewUrl(statusPage); return ( { ): string => { + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean; From e4649f3d76d74d3c2afcbb15c452e8d0c26f84d9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 29 Jun 2026 09:43:37 -0700 Subject: [PATCH 11/23] housekeeping --- charts/helm/checkmate/INSTALLATION.md | 5 ++- .../deployment.yaml} | 0 .../ingress.yaml} | 0 .../service.yaml} | 0 .../templates/{ => db}/mongodb-service.yaml | 0 .../{ => db}/mongodb-statefulsets.yaml | 0 .../checkmate/templates/redis-service.yaml | 11 ------- .../templates/redis-statefulsets.yaml | 33 ------------------- .../deployment.yaml} | 0 .../ingress.yaml} | 0 .../nginx-cm.yaml} | 0 .../service.yaml} | 0 charts/helm/checkmate/values.yaml | 10 ------ docker/.gitignore | 4 --- docker/coolify/build_images.bat | 9 ++--- docker/coolify/build_images.sh | 1 - docker/coolify/redis.Dockerfile | 2 -- .../dev/docker-compose.custom-ca-example.yaml | 1 - docker/dist-arm/docker-compose.yaml | 1 - 19 files changed, 5 insertions(+), 72 deletions(-) rename charts/helm/checkmate/templates/{client-deployment.yaml => client/deployment.yaml} (100%) rename charts/helm/checkmate/templates/{client-ingress.yaml => client/ingress.yaml} (100%) rename charts/helm/checkmate/templates/{client-service.yaml => client/service.yaml} (100%) rename charts/helm/checkmate/templates/{ => db}/mongodb-service.yaml (100%) rename charts/helm/checkmate/templates/{ => db}/mongodb-statefulsets.yaml (100%) delete mode 100644 charts/helm/checkmate/templates/redis-service.yaml delete mode 100644 charts/helm/checkmate/templates/redis-statefulsets.yaml rename charts/helm/checkmate/templates/{server-deployment.yaml => server/deployment.yaml} (100%) rename charts/helm/checkmate/templates/{server-ingress.yaml => server/ingress.yaml} (100%) rename charts/helm/checkmate/templates/{server-nginx-cm.yaml => server/nginx-cm.yaml} (100%) rename charts/helm/checkmate/templates/{server-service.yaml => server/service.yaml} (100%) delete mode 100755 docker/coolify/redis.Dockerfile diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index 06cf9f7b9..9792a696f 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -23,8 +23,7 @@ Edit `values.yaml` to update: - `server.protocol` (usually http or https) - **If upgrading**: Migrate persistence settings from flat structure to nested: - Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - - Old: `persistence.redisSize` → New: `persistence.redis.size` - - Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default) + - Add: `persistence.mongo.storageClass` (leave empty for default) - Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values - **For TLS/HTTPS**: Configure ingress TLS settings (see section below) @@ -32,7 +31,7 @@ Edit `values.yaml` to update: ```bash helm install checkmate ./charts/helm/checkmate ``` -This will deploy the client, server, MongoDB, and Redis components. +This will deploy the client, server, and MongoDB components. ### 4. Verify the deployment Check pods and services: diff --git a/charts/helm/checkmate/templates/client-deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-deployment.yaml rename to charts/helm/checkmate/templates/client/deployment.yaml diff --git a/charts/helm/checkmate/templates/client-ingress.yaml b/charts/helm/checkmate/templates/client/ingress.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-ingress.yaml rename to charts/helm/checkmate/templates/client/ingress.yaml diff --git a/charts/helm/checkmate/templates/client-service.yaml b/charts/helm/checkmate/templates/client/service.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-service.yaml rename to charts/helm/checkmate/templates/client/service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-service.yaml b/charts/helm/checkmate/templates/db/mongodb-service.yaml similarity index 100% rename from charts/helm/checkmate/templates/mongodb-service.yaml rename to charts/helm/checkmate/templates/db/mongodb-service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-statefulsets.yaml b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml similarity index 100% rename from charts/helm/checkmate/templates/mongodb-statefulsets.yaml rename to charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml diff --git a/charts/helm/checkmate/templates/redis-service.yaml b/charts/helm/checkmate/templates/redis-service.yaml deleted file mode 100644 index 2720350da..000000000 --- a/charts/helm/checkmate/templates/redis-service.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: checkmate-redis -spec: - selector: - app: checkmate-redis - ports: - - port: {{ .Values.redis.port }} -{{- end }} diff --git a/charts/helm/checkmate/templates/redis-statefulsets.yaml b/charts/helm/checkmate/templates/redis-statefulsets.yaml deleted file mode 100644 index acf7c85a8..000000000 --- a/charts/helm/checkmate/templates/redis-statefulsets.yaml +++ /dev/null @@ -1,33 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: checkmate-redis -spec: - replicas: 1 - selector: - matchLabels: - app: checkmate-redis - template: - metadata: - labels: - app: checkmate-redis - spec: - containers: - - name: redis - image: {{ .Values.redis.image }} - ports: - - containerPort: {{ .Values.redis.port }} - volumeMounts: - - name: redis-data - mountPath: /data - volumeClaimTemplates: - - metadata: - name: checkmate-redis-persistent-storage - spec: - storageClassName: {{ .Values.persistence.redis.storageClass | quote }} - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }} -{{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server/deployment.yaml similarity index 100% rename from charts/helm/checkmate/templates/server-deployment.yaml rename to charts/helm/checkmate/templates/server/deployment.yaml diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/server/ingress.yaml similarity index 100% rename from charts/helm/checkmate/templates/server-ingress.yaml rename to charts/helm/checkmate/templates/server/ingress.yaml diff --git a/charts/helm/checkmate/templates/server-nginx-cm.yaml b/charts/helm/checkmate/templates/server/nginx-cm.yaml similarity index 100% rename from charts/helm/checkmate/templates/server-nginx-cm.yaml rename to charts/helm/checkmate/templates/server/nginx-cm.yaml diff --git a/charts/helm/checkmate/templates/server-service.yaml b/charts/helm/checkmate/templates/server/service.yaml similarity index 100% rename from charts/helm/checkmate/templates/server-service.yaml rename to charts/helm/checkmate/templates/server/service.yaml diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 9a586dd54..211b29b98 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -36,11 +36,6 @@ server: # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -redis: - enabled: false - image: redis:7.2 - port: 6379 - mongodb: image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0 port: 27017 @@ -55,8 +50,6 @@ secrets: # PAGESPEED_API_KEY: change_me DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db CLIENT_HOST: change_me -# REDIS_HOST: redis -# REDIS_PORT: "6379" # DB_TYPE: MongoDB # TOKEN_TTL: 99d # REFRESH_TOKEN_TTL: 99d @@ -64,7 +57,4 @@ secrets: persistence: mongo: size: 5Gi - storageClass: "" - redis: - size: 1Gi storageClass: "" \ No newline at end of file diff --git a/docker/.gitignore b/docker/.gitignore index 76567d514..4c3f83657 100755 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -2,14 +2,10 @@ !quickstart.sh !build_images.sh dev/mongo/data/* -dev/redis/data/* dist/mongo/data/* -dist/redis/data/* prod/mongo/data/* -prod/redis/data/* dist/docker-compose-test.yaml /dist-mono/mongo/data/* -/dist-mono/redis/data/* /dist-arm/mongo/data/* *.env diff --git a/docker/coolify/build_images.bat b/docker/coolify/build_images.bat index 60fc9aa4a..b940039b6 100644 --- a/docker/coolify/build_images.bat +++ b/docker/coolify/build_images.bat @@ -12,14 +12,11 @@ set dockerfiles[0]=.\docker\coolify\client.Dockerfile set services[1]=bluewaveuptime/uptime_database_mongo set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile -set services[2]=bluewaveuptime/uptime_redis -set dockerfiles[2]=.\docker\coolify\redis.Dockerfile - -set services[3]=bluewaveuptime/uptime_server -set dockerfiles[3]=.\docker\coolify\server.Dockerfile +set services[2]=bluewaveuptime/uptime_server +set dockerfiles[2]=.\docker\coolify\server.Dockerfile REM Loop through each service and build the corresponding image -for /L %%i in (0,1,2,3) do ( +for /L %%i in (0,1,2) do ( set service=!services[%%i]! set dockerfile=!dockerfiles[%%i]! diff --git a/docker/coolify/build_images.sh b/docker/coolify/build_images.sh index 04dfe324c..1d29ae5b1 100755 --- a/docker/coolify/build_images.sh +++ b/docker/coolify/build_images.sh @@ -7,7 +7,6 @@ cd ../../ declare -A services=( ["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile" ["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile" - ["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile" ["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile" ) diff --git a/docker/coolify/redis.Dockerfile b/docker/coolify/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/coolify/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/dev/docker-compose.custom-ca-example.yaml b/docker/dev/docker-compose.custom-ca-example.yaml index a5d7e2f43..e1f185ae9 100644 --- a/docker/dev/docker-compose.custom-ca-example.yaml +++ b/docker/dev/docker-compose.custom-ca-example.yaml @@ -19,7 +19,6 @@ services: # Mount the certs directory (read-only for security) - ./certs:/certs:ro depends_on: - - redis - mongodb # Optional: Add healthcheck to ensure the server starts properly healthcheck: diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index b9aba057c..49f81fe9a 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -9,7 +9,6 @@ services: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret depends_on: From f58825d19f6a1fe64c92c2a20b0c6d75437904f8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 29 Jun 2026 10:03:48 -0700 Subject: [PATCH 12/23] clean up env vars --- charts/helm/checkmate/values.yaml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 211b29b98..4c5a75fd1 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -42,17 +42,11 @@ mongodb: secrets: JWT_SECRET: change_me -# REFRESH_TOKEN_SECRET: change_me -# SYSTEM_EMAIL_ADDRESS: test@example.com -# SYSTEM_EMAIL_PASSWORD: change_me -# SYSTEM_EMAIL_HOST: smtp.example.com -# SYSTEM_EMAIL_PORT: "587" -# PAGESPEED_API_KEY: change_me DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db CLIENT_HOST: change_me -# DB_TYPE: MongoDB -# TOKEN_TTL: 99d -# REFRESH_TOKEN_TTL: 99d + NODE_ENV: production + LOG_LEVEL: info + TOKEN_TTL: 99d persistence: mongo: From c6c41c2e3e1a09cc77a7d795eb4de365e87eeb59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 29 Jun 2026 10:06:34 -0700 Subject: [PATCH 13/23] bare sevice --- charts/helm/checkmate/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 4c5a75fd1..0dbbae89c 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -42,7 +42,7 @@ mongodb: secrets: JWT_SECRET: change_me - DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db + DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db CLIENT_HOST: change_me NODE_ENV: production LOG_LEVEL: info From 35c9618d599be2ec50060a7adfe4ec49b7e06217 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 29 Jun 2026 10:15:29 -0700 Subject: [PATCH 14/23] server->api --- charts/helm/checkmate/INSTALLATION.md | 20 ++++++------- .../templates/{server => api}/deployment.yaml | 24 +++++++++------- .../templates/{server => api}/ingress.yaml | 22 +++++++-------- .../helm/checkmate/templates/api/service.yaml | 9 ++++++ .../templates/client/deployment.yaml | 4 +-- .../{server => client}/nginx-cm.yaml | 6 ++-- .../helm/checkmate/templates/prechecks.yaml | 28 +++++++++---------- .../checkmate/templates/server/service.yaml | 9 ------ charts/helm/checkmate/values.yaml | 11 +++++--- 9 files changed, 70 insertions(+), 63 deletions(-) rename charts/helm/checkmate/templates/{server => api}/deployment.yaml (51%) rename charts/helm/checkmate/templates/{server => api}/ingress.yaml (61%) create mode 100644 charts/helm/checkmate/templates/api/service.yaml rename charts/helm/checkmate/templates/{server => client}/nginx-cm.yaml (84%) delete mode 100644 charts/helm/checkmate/templates/server/service.yaml diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index 9792a696f..ae0f36b8b 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -19,8 +19,8 @@ cd checkmate/charts/helm/checkmate ### 2. Customize values.yaml Edit `values.yaml` to update: -- `client.ingress.host` and `server.ingress.host` with your domain names -- `server.protocol` (usually http or https) +- `client.ingress.host` and `api.ingress.host` with your domain names +- `api.protocol` (usually http or https) - **If upgrading**: Migrate persistence settings from flat structure to nested: - Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - Add: `persistence.mongo.storageClass` (leave empty for default) @@ -31,7 +31,7 @@ Edit `values.yaml` to update: ```bash helm install checkmate ./charts/helm/checkmate ``` -This will deploy the client, server, and MongoDB components. +This will deploy the client, api, and MongoDB components. ### 4. Verify the deployment Check pods and services: @@ -67,7 +67,7 @@ client: enabled: true secretName: checkmate-client-tls -server: +api: protocol: https ingress: enabled: true @@ -77,7 +77,7 @@ server: cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: true - secretName: checkmate-server-tls + secretName: checkmate-api-tls ``` ### Alternative: Using --set flags @@ -87,13 +87,13 @@ You can also enable TLS during installation using Helm's `--set` flags: ```bash helm install checkmate ./charts/helm/checkmate \ --set client.protocol=https \ - --set server.protocol=https \ + --set api.protocol=https \ --set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ --set client.ingress.tls.enabled=true \ --set client.ingress.tls.secretName=checkmate-client-tls \ - --set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ - --set server.ingress.tls.enabled=true \ - --set server.ingress.tls.secretName=checkmate-server-tls + --set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ + --set api.ingress.tls.enabled=true \ + --set api.ingress.tls.secretName=checkmate-api-tls ``` ### Verification @@ -106,7 +106,7 @@ kubectl get certificates # Check certificate details kubectl describe certificate checkmate-client-tls -kubectl describe certificate checkmate-server-tls +kubectl describe certificate checkmate-api-tls # Verify the secrets were created kubectl get secrets | grep checkmate-tls diff --git a/charts/helm/checkmate/templates/server/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml similarity index 51% rename from charts/helm/checkmate/templates/server/deployment.yaml rename to charts/helm/checkmate/templates/api/deployment.yaml index 393964ccd..63bafc625 100644 --- a/charts/helm/checkmate/templates/server/deployment.yaml +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -1,37 +1,41 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: checkmate-server + name: checkmate-api spec: - replicas: 1 + replicas: 1 # single scheduler — seeds/reconciles the jobs collection selector: matchLabels: - app: checkmate-server + app: checkmate-api template: metadata: labels: - app: checkmate-server + app: checkmate-api spec: - {{- with .Values.server.affinity }} + {{- with .Values.api.affinity }} affinity: {{- tpl ( . | toYaml) $ | nindent 8 }} {{- end }} - {{- with .Values.server.tolerations }} + {{- with .Values.api.tolerations }} tolerations: {{- tpl ( . | toYaml) $ | nindent 8 }} {{- end }} containers: - - name: server - image: {{ .Values.server.image }} + - name: api + image: {{ .Values.api.image }} ports: - - containerPort: {{ .Values.server.port }} + - containerPort: {{ .Values.api.port }} envFrom: - secretRef: name: checkmate-secrets - {{- with .Values.server.resources }} + {{- with .Values.api.resources }} resources: {{- tpl ( . | toYaml) $ | nindent 12 }} {{- end }} env: - name: CLIENT_HOST value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" + - name: QUEUE_MODE + value: "primary" + - name: QUEUE_PRIMARY_PROCESSES # false: API schedules only, worker tier processes jobs + value: {{ .Values.api.queuePrimaryProcesses | quote }} diff --git a/charts/helm/checkmate/templates/server/ingress.yaml b/charts/helm/checkmate/templates/api/ingress.yaml similarity index 61% rename from charts/helm/checkmate/templates/server/ingress.yaml rename to charts/helm/checkmate/templates/api/ingress.yaml index 34c9ac0dc..f6b9db114 100644 --- a/charts/helm/checkmate/templates/server/ingress.yaml +++ b/charts/helm/checkmate/templates/api/ingress.yaml @@ -1,11 +1,11 @@ -{{- if .Values.server.ingress.enabled }} +{{- if .Values.api.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: checkmate-server-ingress - {{- if .Values.server.ingress.annotations }} + name: checkmate-api-ingress + {{- if .Values.api.ingress.annotations }} annotations: - {{- range $key, $value := .Values.server.ingress.annotations }} + {{- range $key, $value := .Values.api.ingress.annotations }} {{ $key }}: {{ $value | quote }} {{- end }} {{- end }} @@ -17,22 +17,22 @@ metadata: #nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" #nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}} spec: - ingressClassName: {{ .Values.server.ingress.className }} - {{- if .Values.server.ingress.tls.enabled }} + ingressClassName: {{ .Values.api.ingress.className }} + {{- if .Values.api.ingress.tls.enabled }} tls: - hosts: - - {{ .Values.server.ingress.host }} - secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }} + - {{ .Values.api.ingress.host }} + secretName: {{ default (printf "%s-api-tls" .Release.Name) .Values.api.ingress.tls.secretName }} {{- end }} rules: - - host: {{ .Values.server.ingress.host }} + - host: {{ .Values.api.ingress.host }} http: paths: - path: /api/v1 pathType: Prefix backend: service: - name: checkmate-server + name: checkmate-api port: - number: {{ .Values.server.port }} + number: {{ .Values.api.port }} {{- end }} diff --git a/charts/helm/checkmate/templates/api/service.yaml b/charts/helm/checkmate/templates/api/service.yaml new file mode 100644 index 000000000..d172c72e8 --- /dev/null +++ b/charts/helm/checkmate/templates/api/service.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Service +metadata: + name: checkmate-api +spec: + selector: + app: checkmate-api + ports: + - port: {{ .Values.api.port }} diff --git a/charts/helm/checkmate/templates/client/deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml index 381631ef5..b59d3a4a9 100644 --- a/charts/helm/checkmate/templates/client/deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -27,7 +27,7 @@ spec: - containerPort: {{ .Values.client.port }} env: - name: UPTIME_APP_API_BASE_URL - value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1" + value: "{{ .Values.api.protocol }}://{{ .Values.api.ingress.host }}/api/v1" - name: UPTIME_APP_CLIENT_HOST value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" volumeMounts: @@ -41,4 +41,4 @@ spec: volumes: - name: config-volume configMap: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm diff --git a/charts/helm/checkmate/templates/server/nginx-cm.yaml b/charts/helm/checkmate/templates/client/nginx-cm.yaml similarity index 84% rename from charts/helm/checkmate/templates/server/nginx-cm.yaml rename to charts/helm/checkmate/templates/client/nginx-cm.yaml index 84da55299..95d1ec936 100644 --- a/charts/helm/checkmate/templates/server/nginx-cm.yaml +++ b/charts/helm/checkmate/templates/client/nginx-cm.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: ConfigMap metadata: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm data: default.conf: | server { @@ -22,7 +22,7 @@ data: } # location /api/ { - # proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/; + # proxy_pass http://{{ .Values.api.ingress.host }}:5000/api/; # proxy_http_version 1.1; # proxy_set_header Host $host; # proxy_set_header X-Real-IP $remote_addr; @@ -31,7 +31,7 @@ data: # } location /api-docs/ { - proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/; + proxy_pass http://{{ .Values.api.ingress.host }}:5000/api-docs/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/charts/helm/checkmate/templates/prechecks.yaml b/charts/helm/checkmate/templates/prechecks.yaml index e2b9ec5c2..4cd337db3 100644 --- a/charts/helm/checkmate/templates/prechecks.yaml +++ b/charts/helm/checkmate/templates/prechecks.yaml @@ -2,13 +2,13 @@ {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} {{- end }} -{{- if eq .Values.server.ingress.host "change_me" }} - {{- fail "server.ingress.host must be overridden and not set to 'change_me'" }} +{{- if eq .Values.api.ingress.host "change_me" }} + {{- fail "api.ingress.host must be overridden and not set to 'change_me'" }} {{- end }} -{{- $serverProtocol := .Values.server.protocol }} -{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }} - {{- fail "server.protocol must be either 'http' or 'https'" }} +{{- $apiProtocol := .Values.api.protocol }} +{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }} + {{- fail "api.protocol must be either 'http' or 'https'" }} {{- end }} {{- $clientProtocol := .Values.client.protocol }} @@ -21,28 +21,28 @@ {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }} - {{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }} +{{- if and .Values.api.ingress.tls.enabled (ne $apiProtocol "https") }} + {{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }} {{- end }} {{/* If client runs on https, API must also be https to avoid mixed content */}} -{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }} - {{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }} +{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }} + {{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }} {{- end }} {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{- $cAnn := .Values.client.ingress.annotations | default dict }} -{{- $sAnn := .Values.server.ingress.annotations | default dict }} +{{- $aAnn := .Values.api.ingress.annotations | default dict }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }} -{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }} +{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }} {{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }} {{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }} - {{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} +{{- if and .Values.api.ingress.tls.enabled (not $apiHasIssuer) }} + {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{/* Secret name can be omitted; we default to -client|server-tls in templates */}} +{{/* Secret name can be omitted; we default to -client|api-tls in templates */}} diff --git a/charts/helm/checkmate/templates/server/service.yaml b/charts/helm/checkmate/templates/server/service.yaml deleted file mode 100644 index d8a7fbe86..000000000 --- a/charts/helm/checkmate/templates/server/service.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: checkmate-server -spec: - selector: - app: checkmate-server - ports: - - port: {{ .Values.server.port }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 0dbbae89c..900159b49 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -17,10 +17,13 @@ client: # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -server: +api: image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0 port: 52345 protocol: http + # false: API only schedules, the worker tier processes jobs (requires workers deployed). + # true: API also processes jobs (all-in-one; no separate worker needed). + queuePrimaryProcesses: false ingress: enabled: true host: change_me @@ -31,8 +34,8 @@ server: # cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: false - # secretName: {{ .Release.Name }}-server-tls # Optional; defaults to -server-tls if omitted - # Note: when enabling TLS, also set server.protocol: https and add + # secretName: {{ .Release.Name }}-api-tls # Optional; defaults to -api-tls if omitted + # Note: when enabling TLS, also set api.protocol: https and add # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation @@ -51,4 +54,4 @@ secrets: persistence: mongo: size: 5Gi - storageClass: "" \ No newline at end of file + storageClass: "" From a39792cf0b0a908676e8e4895333b9daa6e146dc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 29 Jun 2026 14:14:42 -0700 Subject: [PATCH 15/23] add readiness and liveness checks to API --- .../checkmate/templates/api/deployment.yaml | 22 ++++++++++++++++++- charts/helm/checkmate/values.yaml | 12 ++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml index 63bafc625..ec2e05d99 100644 --- a/charts/helm/checkmate/templates/api/deployment.yaml +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -24,10 +24,30 @@ spec: - name: api image: {{ .Values.api.image }} ports: - - containerPort: {{ .Values.api.port }} + - name: http + containerPort: {{ .Values.api.port }} envFrom: - secretRef: name: checkmate-secrets + # Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive. + # NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is + # also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5. + livenessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: {{ .Values.api.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.api.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.api.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.api.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: {{ .Values.api.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.api.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.api.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.api.probes.readiness.failureThreshold }} {{- with .Values.api.resources }} resources: {{- tpl ( . | toYaml) $ | nindent 12 }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 900159b49..42c98f73c 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -24,6 +24,18 @@ api: # false: API only schedules, the worker tier processes jobs (requires workers deployed). # true: API also processes jobs (all-in-one; no separate worker needed). queuePrimaryProcesses: false + # Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up). + probes: + liveness: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 ingress: enabled: true host: change_me From 754b0af3a3e610fa95691e96907bf018a0695660 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 30 Jun 2026 09:08:01 -0700 Subject: [PATCH 16/23] update helm charts --- charts/helm/checkmate/INSTALLATION.md | 65 +++++++++++++++++++ .../checkmate/templates/api/deployment.yaml | 7 +- .../templates/client/deployment.yaml | 1 + .../templates/db/mongodb-statefulsets.yaml | 5 +- .../helm/checkmate/templates/prechecks.yaml | 5 ++ .../templates/worker/deployment.yaml | 65 +++++++++++++++++++ .../templates/worker/scaledobject.yaml | 30 +++++++++ charts/helm/checkmate/values.yaml | 41 +++++++++++- 8 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 charts/helm/checkmate/templates/worker/deployment.yaml create mode 100644 charts/helm/checkmate/templates/worker/scaledobject.yaml diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index ae0f36b8b..c0528b66b 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -42,6 +42,71 @@ kubectl get svc Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. +## Scaling: the worker tier & autoscaling + +By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring +jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated, +horizontally-scalable worker tier. + +### Topology + +| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) | +| --- | --- | --- | +| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed | +| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs | + +`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API +and worker tier can never both process (or both ignore) the queue. + +### Enabling the worker tier + +```yaml +worker: + enabled: true + replicas: 2 # used only when autoscaling.enabled = false +``` + +The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on +`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit. + +> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the +> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight +> checks. + +> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are +> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate +> this; full failover to the API would need leader election (not supported). + +### Autoscaling with KEDA + +Backlog-driven autoscaling is **off by default** and requires the +[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with: + +```yaml +worker: + enabled: true + autoscaling: + enabled: true + minReplicaCount: 1 # processing floor — never 0, or checks silently stall + maxReplicaCount: 10 + backlogPerReplica: 50 # target due-job backlog per worker + mongo: + dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING +``` + +A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker +Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment +omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName` +matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0 +backlog → never scales up). + +#### Fallback: Prometheus-adapter HPA (not shipped) + +If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape +the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target +it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes +`now` in code on this path, so the query coercion is unnecessary. + ## Enabling TLS/HTTPS with cert-manager If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers. diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml index ec2e05d99..8433ecd58 100644 --- a/charts/helm/checkmate/templates/api/deployment.yaml +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -23,6 +23,7 @@ spec: containers: - name: api image: {{ .Values.api.image }} + imagePullPolicy: {{ .Values.api.imagePullPolicy }} ports: - name: http containerPort: {{ .Values.api.port }} @@ -57,5 +58,7 @@ spec: value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" - name: QUEUE_MODE value: "primary" - - name: QUEUE_PRIMARY_PROCESSES # false: API schedules only, worker tier processes jobs - value: {{ .Values.api.queuePrimaryProcesses | quote }} + # Derived: when a worker tier is deployed the API only schedules (false); otherwise it is + # all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs. + - name: QUEUE_PRIMARY_PROCESSES + value: {{ not .Values.worker.enabled | quote }} diff --git a/charts/helm/checkmate/templates/client/deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml index b59d3a4a9..2919dbd4d 100644 --- a/charts/helm/checkmate/templates/client/deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -23,6 +23,7 @@ spec: containers: - name: client image: {{ .Values.client.image }} + imagePullPolicy: {{ .Values.client.imagePullPolicy }} ports: - containerPort: {{ .Values.client.port }} env: diff --git a/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml index 8149b77e2..d4e048ee7 100644 --- a/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml +++ b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml @@ -23,6 +23,7 @@ spec: containers: - name: mongodb image: {{ .Values.mongodb.image }} + imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }} ports: - containerPort: {{ .Values.mongodb.port }} command: ["mongod", "--quiet", "--bind_ip_all"] @@ -37,7 +38,9 @@ spec: - metadata: name: checkmate-mongo-persistent-storage spec: - storageClassName: {{ .Values.persistence.mongo.storageClass | quote }} + {{- with .Values.persistence.mongo.storageClass }} + storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}} + {{- end }} accessModes: ["ReadWriteOnce"] resources: requests: diff --git a/charts/helm/checkmate/templates/prechecks.yaml b/charts/helm/checkmate/templates/prechecks.yaml index 4cd337db3..b8ff2ef11 100644 --- a/charts/helm/checkmate/templates/prechecks.yaml +++ b/charts/helm/checkmate/templates/prechecks.yaml @@ -45,4 +45,9 @@ {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} +{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}} +{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }} + {{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }} +{{- end }} + {{/* Secret name can be omitted; we default to -client|api-tls in templates */}} diff --git a/charts/helm/checkmate/templates/worker/deployment.yaml b/charts/helm/checkmate/templates/worker/deployment.yaml new file mode 100644 index 000000000..a4a76b541 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/deployment.yaml @@ -0,0 +1,65 @@ +{{- if .Values.worker.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: checkmate-worker +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count + {{- end }} + selector: + matchLabels: + app: checkmate-worker + template: + metadata: + labels: + app: checkmate-worker + spec: + terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }} + {{- with .Values.worker.affinity }} + affinity: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + containers: + - name: worker + image: {{ .Values.worker.image | default .Values.api.image }} + imagePullPolicy: {{ .Values.worker.imagePullPolicy | default .Values.api.imagePullPolicy }} + ports: + - name: health + containerPort: {{ .Values.worker.healthPort }} + envFrom: + - secretRef: + name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv) + env: + # Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes. + - name: CLIENT_HOST + value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" + - name: QUEUE_MODE + value: "worker" + - name: HEALTH_PORT + value: {{ .Values.worker.healthPort | quote }} + livenessProbe: + httpGet: + path: /livez + port: health + initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }} + {{- with .Values.worker.resources }} + resources: + {{- tpl ( . | toYaml) $ | nindent 12 }} + {{- end }} +{{- end }} diff --git a/charts/helm/checkmate/templates/worker/scaledobject.yaml b/charts/helm/checkmate/templates/worker/scaledobject.yaml new file mode 100644 index 000000000..09fc9a8a3 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/scaledobject.yaml @@ -0,0 +1,30 @@ +{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: checkmate-worker +spec: + scaleTargetRef: + name: checkmate-worker + minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks + maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }} + cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }} + advanced: + horizontalPodAutoscalerConfig: + behavior: + scaleDown: + stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }} + triggers: + - type: mongodb + metadata: + dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }} + collection: {{ .Values.worker.autoscaling.mongo.collection | quote }} + # The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong): + query: >- + { "type": "check", "isActive": true, + "$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] }, + "$or": [ { "lockedUntil": null }, + { "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] } + queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }} + connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets +{{- end }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 42c98f73c..7f8fbc65d 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -1,5 +1,6 @@ client: image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0 + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 80 protocol: http ingress: @@ -19,11 +20,9 @@ client: api: image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0 + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 52345 protocol: http - # false: API only schedules, the worker tier processes jobs (requires workers deployed). - # true: API also processes jobs (all-in-one; no separate worker needed). - queuePrimaryProcesses: false # Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up). probes: liveness: @@ -53,8 +52,44 @@ api: mongodb: image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0 + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 27017 +worker: + # Off by default; existing installs are unchanged. When true, a dedicated worker processes all + # jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the + # API is all-in-one and processes jobs itself. + enabled: false + image: "" # defaults to api.image when empty, same as API image + imagePullPolicy: "" # defaults to api.imagePullPolicy when empty + replicas: 1 # used only when autoscaling.enabled = false + healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics + terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain + resources: {} + affinity: {} + tolerations: {} + probes: + liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: # /readyz — flips to 503 on drain + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + autoscaling: + enabled: false # requires the KEDA operator installed in-cluster + minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks) + maxReplicaCount: 10 + backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker + cooldownPeriod: 300 # seconds before scaling an idle target down + stabilizationWindowSeconds: 300 # damp the interval-boundary herd + mongo: + dbName: uptime_db # must match DB_CONNECTION_STRING's database + collection: jobs + secrets: JWT_SECRET: change_me DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db From c0e33eb4faae150624a325d11403f9712374838e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 30 Jun 2026 09:08:20 -0700 Subject: [PATCH 17/23] skip general rate limiter on dev mode --- server/src/api/middleware/rateLimiter.ts | 16 +++++++++------- server/src/app.ts | 3 ++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/server/src/api/middleware/rateLimiter.ts b/server/src/api/middleware/rateLimiter.ts index 99655149d..839f9377d 100644 --- a/server/src/api/middleware/rateLimiter.ts +++ b/server/src/api/middleware/rateLimiter.ts @@ -1,12 +1,14 @@ import rateLimit from "express-rate-limit"; -export const generalApiLimiter = rateLimit({ - windowMs: 60 * 1000, - limit: 600, - standardHeaders: true, - legacyHeaders: false, - ipv6Subnet: 64, -}); +export const generalApiLimiter = (devMode: boolean) => + rateLimit({ + windowMs: 60 * 1000, + limit: 600, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, + skip: () => devMode, + }); export const authApiLimiter = rateLimit({ windowMs: 60 * 1000, diff --git a/server/src/app.ts b/server/src/app.ts index 94a122137..7cb4b9510 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -38,7 +38,8 @@ export const createApp = ({ }; const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository); - app.use(generalApiLimiter); + const devMode = envSettings.nodeEnv === "development"; + app.use(generalApiLimiter(devMode)); app.use((req, res, next) => { const corsOptions = isPublicStatusPageApiPath(req.method, req.path) From 8f657dcd36cc8c8f89b5e493307ff9cf125b4038 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 30 Jun 2026 10:51:21 -0700 Subject: [PATCH 18/23] fix mongo namespace --- charts/helm/checkmate/templates/secrets.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/charts/helm/checkmate/templates/secrets.yaml b/charts/helm/checkmate/templates/secrets.yaml index af104e7d1..9c99395f4 100644 --- a/charts/helm/checkmate/templates/secrets.yaml +++ b/charts/helm/checkmate/templates/secrets.yaml @@ -4,6 +4,14 @@ {{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }} {{- end }} +{{- /* + The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it + by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there, + so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so + the one connection string resolves from every namespace. External/already-qualified hosts are not + matched (the char after the host name is "." for an FQDN, which the pattern excludes). +*/}} +{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }} apiVersion: v1 kind: Secret metadata: @@ -11,5 +19,9 @@ metadata: type: Opaque stringData: {{- range $key, $value := $secrets }} +{{- if eq $key "DB_CONNECTION_STRING" }} + {{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }} +{{- else }} {{ $key }}: {{ $value | quote }} +{{- end }} {{- end }} \ No newline at end of file From 0ec77a5ab19e8106ee6598b44af6ee5f4f5231a2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 30 Jun 2026 16:58:52 -0700 Subject: [PATCH 19/23] add alias for backward compat --- charts/helm/checkmate/INSTALLATION.md | 45 +++++++++++++++++++ charts/helm/checkmate/templates/_helpers.tpl | 16 +++++++ .../checkmate/templates/api/deployment.yaml | 29 ++++++------ .../helm/checkmate/templates/api/ingress.yaml | 19 ++++---- .../helm/checkmate/templates/api/service.yaml | 3 +- .../templates/client/deployment.yaml | 3 +- .../checkmate/templates/client/nginx-cm.yaml | 7 +-- .../helm/checkmate/templates/prechecks.yaml | 13 +++--- .../templates/worker/deployment.yaml | 5 ++- 9 files changed, 104 insertions(+), 36 deletions(-) create mode 100644 charts/helm/checkmate/templates/_helpers.tpl diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index c0528b66b..7177da86a 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -42,6 +42,51 @@ kubectl get svc Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. +## Upgrading from a previous release + +This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis. +Read this before running `helm upgrade` on an existing install. + +### `server.*` → `api.*` (values rename) + +The value block formerly named `server` is now `api` (`server.image` → `api.image`, +`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`, +`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.). + +**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on +top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The +shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:` +block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins — +don't mix them. + +### Resource renames cause a replace, not a rename + +The Kubernetes objects are renamed alongside the values: + +| Old object | New object | +| --- | --- | +| Deployment/Service `checkmate-server` | `checkmate-api` | +| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` | +| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` | + +Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a +**new ClusterIP** for the API Service — update anything outside the chart that referenced the +`checkmate-server` Service by name. MongoDB data (its PVC) is untouched. + +### Redis removed + +The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the +scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are +unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are +deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:` +keys in your values file are harmless (ignored). + +### New secret defaults + +`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets` +block and merge into your install unless you override them. Set them explicitly if you relied on +different values (e.g. a non-production `NODE_ENV`). + ## Scaling: the worker tier & autoscaling By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring diff --git a/charts/helm/checkmate/templates/_helpers.tpl b/charts/helm/checkmate/templates/_helpers.tpl new file mode 100644 index 000000000..a65766ab8 --- /dev/null +++ b/charts/helm/checkmate/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{- /* +Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that +still pass a legacy `server:` block keep working — this returns the effective API values with any +legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set). + +New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as: + {{- $api := include "checkmate.api" . | fromYaml -}} +then reference `$api.*` instead of `.Values.api.*`. +*/}} +{{- define "checkmate.api" -}} +{{- $api := deepCopy .Values.api -}} +{{- with .Values.server -}} +{{- $api = mergeOverwrite $api (deepCopy .) -}} +{{- end -}} +{{- $api | toYaml -}} +{{- end -}} diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml index 8433ecd58..542c6d0af 100644 --- a/charts/helm/checkmate/templates/api/deployment.yaml +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -12,21 +13,21 @@ spec: labels: app: checkmate-api spec: - {{- with .Values.api.affinity }} + {{- with $api.affinity }} affinity: {{- tpl ( . | toYaml) $ | nindent 8 }} {{- end }} - {{- with .Values.api.tolerations }} + {{- with $api.tolerations }} tolerations: {{- tpl ( . | toYaml) $ | nindent 8 }} {{- end }} containers: - name: api - image: {{ .Values.api.image }} - imagePullPolicy: {{ .Values.api.imagePullPolicy }} + image: {{ $api.image }} + imagePullPolicy: {{ $api.imagePullPolicy }} ports: - name: http - containerPort: {{ .Values.api.port }} + containerPort: {{ $api.port }} envFrom: - secretRef: name: checkmate-secrets @@ -37,19 +38,19 @@ spec: httpGet: path: /api/v1/health port: http - initialDelaySeconds: {{ .Values.api.probes.liveness.initialDelaySeconds }} - periodSeconds: {{ .Values.api.probes.liveness.periodSeconds }} - timeoutSeconds: {{ .Values.api.probes.liveness.timeoutSeconds }} - failureThreshold: {{ .Values.api.probes.liveness.failureThreshold }} + initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ $api.probes.liveness.periodSeconds }} + timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }} + failureThreshold: {{ $api.probes.liveness.failureThreshold }} readinessProbe: httpGet: path: /api/v1/health port: http - initialDelaySeconds: {{ .Values.api.probes.readiness.initialDelaySeconds }} - periodSeconds: {{ .Values.api.probes.readiness.periodSeconds }} - timeoutSeconds: {{ .Values.api.probes.readiness.timeoutSeconds }} - failureThreshold: {{ .Values.api.probes.readiness.failureThreshold }} - {{- with .Values.api.resources }} + initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ $api.probes.readiness.periodSeconds }} + timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }} + failureThreshold: {{ $api.probes.readiness.failureThreshold }} + {{- with $api.resources }} resources: {{- tpl ( . | toYaml) $ | nindent 12 }} {{- end }} diff --git a/charts/helm/checkmate/templates/api/ingress.yaml b/charts/helm/checkmate/templates/api/ingress.yaml index f6b9db114..ae60889f2 100644 --- a/charts/helm/checkmate/templates/api/ingress.yaml +++ b/charts/helm/checkmate/templates/api/ingress.yaml @@ -1,11 +1,12 @@ -{{- if .Values.api.ingress.enabled }} +{{- $api := include "checkmate.api" . | fromYaml -}} +{{- if $api.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: checkmate-api-ingress - {{- if .Values.api.ingress.annotations }} + {{- if $api.ingress.annotations }} annotations: - {{- range $key, $value := .Values.api.ingress.annotations }} + {{- range $key, $value := $api.ingress.annotations }} {{ $key }}: {{ $value | quote }} {{- end }} {{- end }} @@ -17,15 +18,15 @@ metadata: #nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" #nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}} spec: - ingressClassName: {{ .Values.api.ingress.className }} - {{- if .Values.api.ingress.tls.enabled }} + ingressClassName: {{ $api.ingress.className }} + {{- if $api.ingress.tls.enabled }} tls: - hosts: - - {{ .Values.api.ingress.host }} - secretName: {{ default (printf "%s-api-tls" .Release.Name) .Values.api.ingress.tls.secretName }} + - {{ $api.ingress.host }} + secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }} {{- end }} rules: - - host: {{ .Values.api.ingress.host }} + - host: {{ $api.ingress.host }} http: paths: - path: /api/v1 @@ -34,5 +35,5 @@ spec: service: name: checkmate-api port: - number: {{ .Values.api.port }} + number: {{ $api.port }} {{- end }} diff --git a/charts/helm/checkmate/templates/api/service.yaml b/charts/helm/checkmate/templates/api/service.yaml index d172c72e8..dfea901c4 100644 --- a/charts/helm/checkmate/templates/api/service.yaml +++ b/charts/helm/checkmate/templates/api/service.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: v1 kind: Service metadata: @@ -6,4 +7,4 @@ spec: selector: app: checkmate-api ports: - - port: {{ .Values.api.port }} + - port: {{ $api.port }} diff --git a/charts/helm/checkmate/templates/client/deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml index 2919dbd4d..006ccfaa1 100644 --- a/charts/helm/checkmate/templates/client/deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -28,7 +29,7 @@ spec: - containerPort: {{ .Values.client.port }} env: - name: UPTIME_APP_API_BASE_URL - value: "{{ .Values.api.protocol }}://{{ .Values.api.ingress.host }}/api/v1" + value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1" - name: UPTIME_APP_CLIENT_HOST value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" volumeMounts: diff --git a/charts/helm/checkmate/templates/client/nginx-cm.yaml b/charts/helm/checkmate/templates/client/nginx-cm.yaml index 95d1ec936..0d23aac0a 100644 --- a/charts/helm/checkmate/templates/client/nginx-cm.yaml +++ b/charts/helm/checkmate/templates/client/nginx-cm.yaml @@ -1,5 +1,6 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: v1 -kind: ConfigMap +kind: ConfigMap metadata: name: checkmate-client-nginx-cm data: @@ -22,7 +23,7 @@ data: } # location /api/ { - # proxy_pass http://{{ .Values.api.ingress.host }}:5000/api/; + # proxy_pass http://{{ $api.ingress.host }}:5000/api/; # proxy_http_version 1.1; # proxy_set_header Host $host; # proxy_set_header X-Real-IP $remote_addr; @@ -31,7 +32,7 @@ data: # } location /api-docs/ { - proxy_pass http://{{ .Values.api.ingress.host }}:5000/api-docs/; + proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/charts/helm/checkmate/templates/prechecks.yaml b/charts/helm/checkmate/templates/prechecks.yaml index b8ff2ef11..cc9f6b9a1 100644 --- a/charts/helm/checkmate/templates/prechecks.yaml +++ b/charts/helm/checkmate/templates/prechecks.yaml @@ -1,12 +1,13 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} {{- if eq .Values.client.ingress.host "change_me" }} {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} {{- end }} -{{- if eq .Values.api.ingress.host "change_me" }} - {{- fail "api.ingress.host must be overridden and not set to 'change_me'" }} +{{- if eq $api.ingress.host "change_me" }} + {{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }} {{- end }} -{{- $apiProtocol := .Values.api.protocol }} +{{- $apiProtocol := $api.protocol }} {{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }} {{- fail "api.protocol must be either 'http' or 'https'" }} {{- end }} @@ -21,7 +22,7 @@ {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }} {{- end }} -{{- if and .Values.api.ingress.tls.enabled (ne $apiProtocol "https") }} +{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }} {{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }} {{- end }} @@ -32,7 +33,7 @@ {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{- $cAnn := .Values.client.ingress.annotations | default dict }} -{{- $aAnn := .Values.api.ingress.annotations | default dict }} +{{- $aAnn := $api.ingress.annotations | default dict }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }} {{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }} @@ -41,7 +42,7 @@ {{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{- if and .Values.api.ingress.tls.enabled (not $apiHasIssuer) }} +{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }} {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} diff --git a/charts/helm/checkmate/templates/worker/deployment.yaml b/charts/helm/checkmate/templates/worker/deployment.yaml index a4a76b541..1252ee8ea 100644 --- a/charts/helm/checkmate/templates/worker/deployment.yaml +++ b/charts/helm/checkmate/templates/worker/deployment.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} {{- if .Values.worker.enabled }} apiVersion: apps/v1 kind: Deployment @@ -26,8 +27,8 @@ spec: {{- end }} containers: - name: worker - image: {{ .Values.worker.image | default .Values.api.image }} - imagePullPolicy: {{ .Values.worker.imagePullPolicy | default .Values.api.imagePullPolicy }} + image: {{ .Values.worker.image | default $api.image }} + imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }} ports: - name: health containerPort: {{ .Values.worker.healthPort }} From 29d05c1e57304e8d78444a359a1b0075faac19b3 Mon Sep 17 00:00:00 2001 From: Harshitbro-ctrlv Date: Thu, 2 Jul 2026 12:08:17 +0530 Subject: [PATCH 20/23] feat(status-page): add minimal theme --- client/src/Pages/StatusPage/Status/index.tsx | 6 ++ .../Status/themes/minimal/styles.ts | 66 +++++++++++++++++++ .../Pages/StatusPage/Status/themes/tokens.ts | 38 +++++++++++ client/src/Types/StatusPage.ts | 8 ++- client/src/locales/en.json | 4 ++ .../domain/status-pages/status-page.type.ts | 2 +- 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/StatusPage/Status/themes/minimal/styles.ts diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 5ccc5f44c..3889adb85 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -38,6 +38,7 @@ import { BoldHero } from "@/Pages/StatusPage/Status/themes/bold/BoldHero"; import { editorialStyles } from "@/Pages/StatusPage/Status/themes/editorial/styles"; import { EditorialHeader } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHeader"; import { EditorialHero } from "@/Pages/StatusPage/Status/themes/editorial/EditorialHero"; +import { minimalStyles } from "@/Pages/StatusPage/Status/themes/minimal/styles"; const THEME_CONFIGS: Record> = { refined: { @@ -63,6 +64,11 @@ const THEME_CONFIGS: Record> = { HeroSlot: EditorialHero, overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, }, + minimal: { + createStyles: minimalStyles, + HeaderSlot: RefinedHeader, + HeroSlot: RefinedHero, + }, }; const StatusPageView = () => { diff --git a/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts new file mode 100644 index 000000000..27370c0ad --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts @@ -0,0 +1,66 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import type { OverallTone } from "../shared/overallStatus"; +import { refinedStyles, type RefinedStyles } from "../refined/styles"; + +type FlatSx = Exclude, readonly unknown[] | ((theme: Theme) => unknown)>; + +const extend = (base: SxProps, overrides: SxProps): SxProps => [ + base as FlatSx, + overrides as FlatSx, +]; + +export const minimalStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): RefinedStyles => { + const base = refinedStyles(tokens, isDark); + + return { + ...base, + page: extend(base.page, { + maxWidth: 820, + p: { xs: "32px 16px 64px", md: "48px 20px 72px" }, + }), + top: extend(base.top, { + mb: "44px", + pb: "16px", + borderBottom: `1px solid ${tokens.border}`, + }), + logoMono: extend(base.logoMono, { + borderRadius: "50%", + background: tokens.text, + }), + hero: extend(base.hero, { + alignItems: "center", + background: "transparent", + border: 0, + boxShadow: "none", + p: 0, + mb: "40px", + }), + heroRow: extend(base.heroRow, { justifyContent: "center" }), + heroTitle: extend(base.heroTitle, { + flex: "initial", + fontSize: { xs: 22, md: 28 }, + fontWeight: 650, + textAlign: "center", + }), + heroIcon: (_tone: OverallTone) => ({ display: "none" }), + heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }), + chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }), + card: extend(base.card, { + boxShadow: "none", + transition: "none", + "&:hover": { transform: "none", boxShadow: "none" }, + }), + cardRow: extend(base.cardRow, { p: "14px 16px" }), + badge: (tone) => + extend(base.badge(tone), { + background: "transparent", + p: 0, + }), + gauge: extend(base.gauge, { borderRadius: tokens.radius }), + footer: extend(base.footer, { mt: "48px", fontSize: 11 }), + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts index f84b6f100..fdb251c07 100644 --- a/client/src/Pages/StatusPage/Status/themes/tokens.ts +++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts @@ -191,9 +191,47 @@ const editorial: ThemeVariants = { }, }; +const minimal: ThemeVariants = { + light: { + bg: "#ffffff", + surface: "#ffffff", + border: "#dfe3e8", + text: "#17191c", + textMuted: "#667085", + up: "#12a174", + upStrong: "#087a57", + upSoft: "#e7f7f1", + degraded: "#b54708", + degradedSoft: "#fff1dc", + down: "#c4323f", + downSoft: "#fde8ea", + warn: "#c27803", + warnSoft: "#fff3d6", + radius: "6px", + }, + dark: { + bg: "#0d0f12", + surface: "#111419", + border: "#2b3038", + text: "#f2f4f7", + textMuted: "#98a2b3", + up: "#32d6a0", + upStrong: "#1eb783", + upSoft: "rgba(50,214,160,0.14)", + degraded: "#f0a44b", + degradedSoft: "rgba(240,164,75,0.16)", + down: "#ff6675", + downSoft: "rgba(255,102,117,0.16)", + warn: "#f2b84b", + warnSoft: "rgba(242,184,75,0.16)", + radius: "6px", + }, +}; + export const themeTokens: Record = { refined, modern, bold, editorial, + minimal, }; diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 822ab7345..bbe1f41f2 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -21,7 +21,13 @@ export const getMonitorTypeLabel = ( return key ? t(key) : type; }; -export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const; +export const STATUS_PAGE_THEMES = [ + "refined", + "modern", + "bold", + "editorial", + "minimal", +] as const; export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number]; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e4bbc0f69..f62679b4a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1585,6 +1585,10 @@ "editorial": { "name": "Editorial", "description": "Serif headings and hairline dividers. Distinctive and calm." + }, + "minimal": { + "name": "Minimal", + "description": "Flat, focused, and compact with only the essentials." } } }, diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index db08b7398..254a411e1 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,7 +1,7 @@ export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; -export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const; +export const StatusPageThemes = ["refined", "modern", "bold", "editorial", "minimal"] as const; export type StatusPageTheme = (typeof StatusPageThemes)[number]; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; From e3d63a1cb4e5144847979d2bf810e446580813f3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Jul 2026 09:11:26 -0700 Subject: [PATCH 21/23] bump version --- charts/helm/checkmate/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/helm/checkmate/Chart.yaml b/charts/helm/checkmate/Chart.yaml index 97f320e68..b3908cffb 100644 --- a/charts/helm/checkmate/Chart.yaml +++ b/charts/helm/checkmate/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: checkmate-chart description: A Helm chart for Checkmate App type: application -version: 0.1.0 -appVersion: "2.3" +version: 0.2.0 +appVersion: "3.2.0" From ff536f40a2e5cb9971bcac252c3397776c39fb9d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Jul 2026 09:44:37 -0700 Subject: [PATCH 22/23] use version tag --- charts/helm/checkmate/INSTALLATION.md | 21 +++++++++++++++++++ charts/helm/checkmate/templates/_helpers.tpl | 19 +++++++++++++++++ .../checkmate/templates/api/deployment.yaml | 2 +- .../templates/client/deployment.yaml | 2 +- .../templates/db/mongodb-statefulsets.yaml | 2 +- .../templates/worker/deployment.yaml | 2 +- charts/helm/checkmate/values.yaml | 10 ++++++--- 7 files changed, 51 insertions(+), 7 deletions(-) diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index 7177da86a..6bc1f3ecd 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -87,6 +87,27 @@ keys in your values file are harmless (ignored). block and merge into your install unless you override them. Set them explicitly if you relied on different values (e.g. a non-production `NODE_ENV`). +### Image tags follow the chart's `appVersion` + +Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's +`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the +chart. You can still pin per tier: + +```yaml +api: + image: ghcr.io/bluewave-labs/checkmate-backend # repo only + tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag +``` + +- `.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and + `api.image`) unless you set `worker.tag` / `worker.image`. +- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is + used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged. + +**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is +cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or +let it default to `appVersion`) for reproducible, rollback-able deploys. + ## Scaling: the worker tier & autoscaling By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring diff --git a/charts/helm/checkmate/templates/_helpers.tpl b/charts/helm/checkmate/templates/_helpers.tpl index a65766ab8..69a1b259b 100644 --- a/charts/helm/checkmate/templates/_helpers.tpl +++ b/charts/helm/checkmate/templates/_helpers.tpl @@ -14,3 +14,22 @@ then reference `$api.*` instead of `.Values.api.*`. {{- end -}} {{- $api | toYaml -}} {{- end -}} + +{{- /* +Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of +truth for tags: bumping Chart.appVersion moves every tier at once. Call as: + {{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }} + +If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps +legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest"). +A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still +appended. Otherwise the tag is `tag` if set, else the chart's appVersion. +*/}} +{{- define "checkmate.image" -}} +{{- $lastSegment := .image | splitList "/" | last -}} +{{- if contains ":" $lastSegment -}} +{{- .image -}} +{{- else -}} +{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml index 542c6d0af..6469d3bdd 100644 --- a/charts/helm/checkmate/templates/api/deployment.yaml +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -23,7 +23,7 @@ spec: {{- end }} containers: - name: api - image: {{ $api.image }} + image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }} imagePullPolicy: {{ $api.imagePullPolicy }} ports: - name: http diff --git a/charts/helm/checkmate/templates/client/deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml index 006ccfaa1..a98e6a648 100644 --- a/charts/helm/checkmate/templates/client/deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -23,7 +23,7 @@ spec: {{- end }} containers: - name: client - image: {{ .Values.client.image }} + image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }} imagePullPolicy: {{ .Values.client.imagePullPolicy }} ports: - containerPort: {{ .Values.client.port }} diff --git a/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml index d4e048ee7..16c228c2b 100644 --- a/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml +++ b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml @@ -22,7 +22,7 @@ spec: {{- end }} containers: - name: mongodb - image: {{ .Values.mongodb.image }} + image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }} imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }} ports: - containerPort: {{ .Values.mongodb.port }} diff --git a/charts/helm/checkmate/templates/worker/deployment.yaml b/charts/helm/checkmate/templates/worker/deployment.yaml index 1252ee8ea..0b0849455 100644 --- a/charts/helm/checkmate/templates/worker/deployment.yaml +++ b/charts/helm/checkmate/templates/worker/deployment.yaml @@ -27,7 +27,7 @@ spec: {{- end }} containers: - name: worker - image: {{ .Values.worker.image | default $api.image }} + image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }} imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }} ports: - name: health diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 7f8fbc65d..b2126fe20 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -1,5 +1,6 @@ client: - image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-client + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 80 protocol: http @@ -19,7 +20,8 @@ client: # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation api: - image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-backend + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 52345 protocol: http @@ -51,7 +53,8 @@ api: # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation mongodb: - image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-mongo + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 27017 @@ -61,6 +64,7 @@ worker: # API is all-in-one and processes jobs itself. enabled: false image: "" # defaults to api.image when empty, same as API image + tag: "" # defaults to api.tag (-> Chart.appVersion) when empty imagePullPolicy: "" # defaults to api.imagePullPolicy when empty replicas: 1 # used only when autoscaling.enabled = false healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics From 8671685cf97ebaace8cb61add9dbbc54d31bdd65 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Jul 2026 10:07:42 -0700 Subject: [PATCH 23/23] bump version --- charts/helm/checkmate/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/helm/checkmate/Chart.yaml b/charts/helm/checkmate/Chart.yaml index b3908cffb..c9433f690 100644 --- a/charts/helm/checkmate/Chart.yaml +++ b/charts/helm/checkmate/Chart.yaml @@ -3,4 +3,4 @@ name: checkmate-chart description: A Helm chart for Checkmate App type: application version: 0.2.0 -appVersion: "3.2.0" +appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1)