diff --git a/_TODO.md b/_TODO.md index 5b5d7d751..69fbc093c 100644 --- a/_TODO.md +++ b/_TODO.md @@ -181,13 +181,7 @@ Need a tooltip component for consistency. List to add tooltips to: ## Downloads / Gated Content -- We need a gating system, where the user gets a token to be able to download a PDF and the token is checked before downloading. If a reader has already given their email address - newsletter signup, contact form, download registration, then the download CTA on short form articles should go directly to the HTML version of the deep dive, and it should have a PDF download button. - -- Make `/deep-dive/` SSR loaded so we can check if they've given their e-mail address. - -- If they haven't and they get to the path from sharing a link or something, we should use the `index.astro` file in that directory to explain that they've accessed gated content, and ask for their e-mail address. Then redirect to the content they want when they give it. - -- If they've given their email link, the content switcher button to switch should be enabled. +- We need a gating system, where the user gets a token to be able to download a PDF and the token is checked before downloading. If a reader has already given their email address - newsletter signup, contact form, download registration, then the download CTA on short form articles should show the PDF download button instead of trying to collect email addresses again. - We had this copyright statement for articles, it should be in the front matter of PDFs: @@ -197,8 +191,6 @@ The text, diagrams, and images in this work are licensed under CC BY-NC 4.0 All code samples in this article are licensed under the MIT License. Feel free to use, modify, and distribute them in any project. ``` -- When someone's already given their email address, the Download CTA should just have a download button - not require them to sign up again - ## Search - How do we handle the keywords in the long form / pdf files from a search perspective? Can we return the result in the search results if the short form content is not returned in the search results, and highlight it somehow in the search results to show that it is gated content? And clicking on its link takes the user to the Download page for that item? @@ -218,8 +210,6 @@ https://mermaid.js.org/config/directives.html - Uppy, Tus server, whatever other server needed for file upload on Contact Form component -- Add Inset component and convert `text` code blocks to use it. - - Focus-visible / active handling on ToC nav items - Style "Share to Mastodon" modal in src/components/Social/Mastodon/client/index.ts @@ -236,11 +226,7 @@ https://mermaid.js.org/config/directives.html - Improve `` styling: https://codepen.io/ire/pen/NoqWpm -- Service worker isn't caching favicon - -- Image component with zoom + and -, and magnify glass. Modal to expand full size. - -- Don't lazy load hero image +- Moving the scroll bar up quickly with the mouse seems to make the header logic break - the Switcher component and Breadcrumbs are hidden under the header ## List Component @@ -256,18 +242,12 @@ https://mermaid.js.org/config/directives.html ## Content Issues -- "### Geographic/Currency Mismatches" in deep-dive/cdn-edge-caching-cache-keys-vary-headers has a table -> callout -> table back to back - - Need an article on OpenStack - Add a Social Share on some compelling text in each article - Home page reorganization: move the "What I Deliver" box from the Hero into the Backstage image. Move the Backstage image / video to the hero. -- Determine if any of the python code blocks should have `#!/bin/python` added - -- Need a Q & A format to use in `blameless-postmortem-incident-analysis-systemic-causes`. Might be one of the list formats. - - We need to check for short form and deep article articles where the deep-dive index.pdf has a non-featured tag like "argo-cd" only in the pdf.mdx. In those cases, we should make sure the callout for the deep dive includes the name of that non-featured (technology) tag - deep-dive/kubernetes-pod-resource-requests-limits-qos-classes is showing the Download hero image @@ -278,12 +258,7 @@ https://mermaid.js.org/config/directives.html - Need to update Case Studies with lists and tables too -### This file was badly mangled during refactoring, need to compare against original: - -src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx - -├── -└── +### Snippets @@ -307,6 +282,7 @@ src/content/articles/internal-developer-portal-platform-self-service-actions/pdf /> -### Spacing out the boxes: +#### Stretching the badge out to the same size and centering text: + + + +td:nth-of-type(2)]:!font-normal", + }} diff --git a/package-lock.json b/package-lock.json index dcc487342..8f7de045e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,13 @@ ], "dependencies": { "@adobe/remark-gridtables": "^3.0.18", - "@astrojs/check": "0.9.7", + "@astrojs/check": "0.9.8", "@astrojs/db": "^0.20.0", - "@astrojs/mdx": "5.0.0", - "@astrojs/preact": "5.0.0", + "@astrojs/mdx": "5.0.1", + "@astrojs/preact": "5.0.1", "@astrojs/rss": "4.0.17", "@astrojs/sitemap": "^3.7.1", - "@astrojs/vercel": "^10.0.0", + "@astrojs/vercel": "^10.0.1", "@axe-core/playwright": "^4.11.1", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1", "@eslint/js": "10.0.1", @@ -32,7 +32,7 @@ "@nanostores/persistent": "^1.3.3", "@playwright/browser-chromium": "^1.58.2", "@playwright/test": "1.58.2", - "@semantic-ui/astro-lit": "^5.1.2", + "@semantic-ui/astro-lit": "^5.3.0", "@sentry/astro": "^10.43.0", "@sentry/browser": "^10.43.0", "@shikijs/transformers": "^4.0.2", @@ -58,8 +58,8 @@ "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.1", "@types/to-ico": "1.1.3", - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", "@ungap/structured-clone": "^1.3.0", "@uppy/audio": "^3.1.0", "@uppy/box": "^4.1.0", @@ -75,11 +75,11 @@ "@vitest/coverage-v8": "^4.1.0", "@webcomponents/template-shadowroot": "^0.2.1", "alex": "^11.0.1", - "astro": "6.0.4", + "astro": "6.0.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.10.1", "astro-vtbot": "^2.1.12", - "baseline-browser-mapping": "^2.10.7", + "baseline-browser-mapping": "^2.10.8", "canvas-confetti": "^1.9.4", "confusing-browser-globals": "1.0.11", "cross-env": "^10.1.0", @@ -109,14 +109,14 @@ "is-whitespace-character": "^2.0.1", "isomorphic-git": "^1.37.4", "js-cookie": "^3.0.5", - "jsdom": "^28.1.0", + "jsdom": "^29.0.0", "libphonenumber-js": "1.12.40", "lighthouse": "^13.0.3", "lit": "^3.3.2", "markdownlint-cli2": "^0.21.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.13.0", - "nanostores": "^1.1.1", + "nanostores": "^1.2.0", "nodemailer": "^8.0.2", "npm": "^11.11.1", "playwright-lighthouse": "^4.0.0", @@ -149,7 +149,7 @@ "remark-stringify": "^11.0.0", "remark-supersub": "^1.0.0", "remark-video": "^0.10.0", - "resend": "^6.9.3", + "resend": "^6.9.4", "retext": "^9.0.0", "retext-smartypants": "^6.2.0", "rimraf": "6.1.3", @@ -161,7 +161,7 @@ "stylelint": "^17.4.0", "stylelint-config-standard": "^40.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", - "stylelint-order": "8.0.0", + "stylelint-order": "8.1.1", "tailwindcss": "^4.2.1", "temp-dir": "3.0.0", "timezones-ical-library": "^2.1.3", @@ -169,7 +169,7 @@ "to-ico": "1.1.5", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.57.0", + "typescript-eslint": "8.57.1", "unified": "^11.0.5", "unist": "^0.0.1", "unist-util-inspect": "^8.1.0", @@ -184,19 +184,13 @@ "zod": "4.3.6" }, "engines": { - "node": ">=22.0.0 <23.0.0", + "node": ">=24.0.0 <25.0.0", "npm": ">=10.0.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.59.0" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "license": "MIT" - }, "node_modules/@adobe/mdast-util-gridtables": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/@adobe/mdast-util-gridtables/-/mdast-util-gridtables-4.0.17.tgz", @@ -245,44 +239,50 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", + "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", + "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -295,12 +295,12 @@ "license": "MIT" }, "node_modules/@astrojs/check": { - "version": "0.9.7", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.7.tgz", - "integrity": "sha512-dA7U5/OFg8/xaMUb2vUOOJuuJXnMpHy6F0BM8ZhL7WT5OkTBwJ0GoW38n4fC4CXt+lT9mLWL0y8Pa74tFByBpQ==", + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz", + "integrity": "sha512-LDng8446QLS5ToKjRHd3bgUdirvemVVExV7nRyJfW2wV36xuv7vDxwy5NWN9zqeSEDgg0Tv84sP+T3yEq+Zlkw==", "license": "MIT", "dependencies": { - "@astrojs/language-server": "^2.16.1", + "@astrojs/language-server": "^2.16.5", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^17.7.2" @@ -313,9 +313,9 @@ } }, "node_modules/@astrojs/compiler": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", - "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", "license": "MIT" }, "node_modules/@astrojs/db": { @@ -383,28 +383,28 @@ } }, "node_modules/@astrojs/language-server": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.1.tgz", - "integrity": "sha512-OzTpyEPeCPLpp0oyeI/fSCBYemlVos0GewCTYglAW+TAJAMR3nRl5nYlf9ESMFy97SlptZCsRdKlQXDvFHJRNQ==", - "license": "MIT", - "dependencies": { - "@astrojs/compiler": "^2.10.3", - "@astrojs/yaml2ts": "^0.2.2", - "@jridgewell/sourcemap-codec": "^1.4.15", - "@volar/kit": "~2.4.23", - "@volar/language-core": "~2.4.23", - "@volar/language-server": "~2.4.23", - "@volar/language-service": "~2.4.23", - "fast-glob": "^3.2.12", + "version": "2.16.5", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.5.tgz", + "integrity": "sha512-MEQvrbuiFDEo+LCO4vvYuTr3eZ4IluZ/n4BbUv77AWAJNEj/n0j7VqTvdL1rGloNTIKZTUd46p5RwYKsxQGY8w==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.1", + "@astrojs/yaml2ts": "^0.2.3", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@volar/kit": "~2.4.28", + "@volar/language-core": "~2.4.28", + "@volar/language-server": "~2.4.28", + "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", - "volar-service-css": "0.0.66", - "volar-service-emmet": "0.0.66", - "volar-service-html": "0.0.66", - "volar-service-prettier": "0.0.66", - "volar-service-typescript": "0.0.66", - "volar-service-typescript-twoslash-queries": "0.0.66", - "volar-service-yaml": "0.0.66", - "vscode-html-languageservice": "^5.5.2", + "tinyglobby": "^0.2.15", + "volar-service-css": "0.0.70", + "volar-service-emmet": "0.0.70", + "volar-service-html": "0.0.70", + "volar-service-prettier": "0.0.70", + "volar-service-typescript": "0.0.70", + "volar-service-typescript-twoslash-queries": "0.0.70", + "volar-service-yaml": "0.0.70", + "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "bin": { @@ -452,9 +452,9 @@ } }, "node_modules/@astrojs/mdx": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.0.tgz", - "integrity": "sha512-J4rW6eT+qgVw7+RXdBYO4vYyWGeXXQp8wop9dXsOlLzIsVSxyttMCgkGCWvIR2ogBqKqeYgI6YDW93PaDHkCaA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-5.0.1.tgz", + "integrity": "sha512-xfvc9MuV/5Kl6JaiYYEFi7ilbGYyaaOboH+gH8f2jHAZ2pmmHtnSrewS03vNeruBsa8rtS3X8NHJrqeZt+0wLg==", "license": "MIT", "dependencies": { "@astrojs/markdown-remark": "7.0.0", @@ -475,13 +475,13 @@ "node": "^20.19.1 || >=22.12.0" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" } }, "node_modules/@astrojs/preact": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@astrojs/preact/-/preact-5.0.0.tgz", - "integrity": "sha512-KJBLQtKxmx/MnNMqDNFFp2N6o430eyx90uaLKJS4IzUnR0fOgekyGOPjE+3deuetIS+7uNcp1kALpLPG21/tFQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/preact/-/preact-5.0.1.tgz", + "integrity": "sha512-KGX2Eku/6Og21+zoIvIDCGoMbMvX+lX4mCxx2A7FUBg539jkw6czmRpnm0VEIy5RvNOsTh7AAtzgmnB00t2c1A==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.8.0", @@ -705,9 +705,9 @@ } }, "node_modules/@astrojs/vercel": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@astrojs/vercel/-/vercel-10.0.0.tgz", - "integrity": "sha512-U80Lswq1zZporn8DFBrtmAkjQyUns9lN22YPoFGV1NsvKJJy/2XGuoo+NDRK8C98eqpUOLtwXtFFmw0QDV5JYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@astrojs/vercel/-/vercel-10.0.1.tgz", + "integrity": "sha512-Ghl8L2ckDuNbfHbYYTRymUn87YrNTWD64Zz0zwYaGlZDkrG2nT3pmAU5BqzfOOV4Yvzgqxea+HEiEn6puefQZQ==", "license": "MIT", "dependencies": { "@astrojs/internal-helpers": "0.8.0", @@ -719,7 +719,7 @@ "tinyglobby": "^0.2.15" }, "peerDependencies": { - "astro": "^6.0.0-alpha.0" + "astro": "^6.0.0" } }, "node_modules/@astrojs/vercel/node_modules/@vercel/analytics": { @@ -761,12 +761,12 @@ } }, "node_modules/@astrojs/yaml2ts": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.2.tgz", - "integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz", + "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==", "license": "MIT", "dependencies": { - "yaml": "^2.5.0" + "yaml": "^2.8.2" } }, "node_modules/@axe-core/playwright": { @@ -2424,9 +2424,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", - "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "funding": [ { "type": "github", @@ -2466,9 +2466,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", - "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "funding": [ { "type": "github", @@ -2481,8 +2481,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.1", - "@csstools/css-calc": "^3.0.0" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { "node": ">=20.19.0" @@ -2515,9 +2515,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.28", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", - "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "funding": [ { "type": "github", @@ -2528,7 +2528,15 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -2639,8 +2647,9 @@ } }, "node_modules/@emmetio/css-parser": { - "version": "0.4.0", - "resolved": "git+ssh://git@github.com/ramya-rao-a/css-parser.git#370c480ac103bd17c7bcfb34bf5d577dc40d3660", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", "license": "MIT", "dependencies": { "@emmetio/stream-reader": "^2.2.0", @@ -3324,9 +3333,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -6671,9 +6680,9 @@ "license": "MIT" }, "node_modules/@semantic-ui/astro-lit": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@semantic-ui/astro-lit/-/astro-lit-5.1.2.tgz", - "integrity": "sha512-FZ7J9oLWX08WK8G87BtrDZ+wRbc3Ui83sDr3t2fkTp+ZsbU3vGsywq7jS9SMyIaGbFNUARk7kUTUsSBtpLDLpg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@semantic-ui/astro-lit/-/astro-lit-5.3.0.tgz", + "integrity": "sha512-IQ/EPmHg7RQUfvwhhp1uy+zcZ+037AVs7fGpfLQLfhcQNg+mjAeFqu6Eyx6X2YSH6Jt4ZvyDqRzIkCj3gxyBrA==", "license": "MIT", "dependencies": { "@lit-labs/ssr": "^3.3.1", @@ -8638,16 +8647,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -8660,7 +8669,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -8675,15 +8684,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3" }, "engines": { @@ -8699,13 +8708,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "engines": { @@ -8720,13 +8729,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8737,9 +8746,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8753,14 +8762,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -8777,9 +8786,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8790,15 +8799,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8865,15 +8874,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8888,12 +8897,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -12487,13 +12496,13 @@ } }, "node_modules/@volar/kit": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.26.tgz", - "integrity": "sha512-shgNg7PbV8SIxxQLOQh5zMr8KV0JvdG9If0MwJb5L1HMrBU91jBxR0ANi2OJPMMme6/l1vIYm4hCaO6W2JaEcQ==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", + "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", "license": "MIT", "dependencies": { - "@volar/language-service": "2.4.26", - "@volar/typescript": "2.4.26", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" @@ -12503,23 +12512,23 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.26.tgz", - "integrity": "sha512-hH0SMitMxnB43OZpyF1IFPS9bgb2I3bpCh76m2WEK7BE0A0EzpYsRp0CCH2xNKshr7kacU5TQBLYn4zj7CG60A==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.26" + "@volar/source-map": "2.4.28" } }, "node_modules/@volar/language-server": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.26.tgz", - "integrity": "sha512-Xsyu+VDgM8TyVkQfBz2aIViSEOgH2un0gIJlp0M8rssDDLCqr4ssQzwHOyPf7sT7UIjrlAMnJvRkC/u0mmgtYw==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", + "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.26", - "@volar/language-service": "2.4.26", - "@volar/typescript": "2.4.26", + "@volar/language-core": "2.4.28", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", @@ -12529,30 +12538,30 @@ } }, "node_modules/@volar/language-service": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.26.tgz", - "integrity": "sha512-ZBPRR1ytXttSV5X4VPvEQR/glxs+7/4IOJIBCOW3/EJk4z77R4mF2y4wM3fNgOXXZT5h16j3sC5w+LGNkz2VlA==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", + "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.26", + "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "node_modules/@volar/source-map": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.26.tgz", - "integrity": "sha512-JJw0Tt/kSFsIRmgTQF4JSt81AUSI1aEye5Zl65EeZ8H35JHnTvFGmpDOBn5iOxd48fyGE+ZvZBp5FcgAy/1Qhw==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.26.tgz", - "integrity": "sha512-N87ecLD48Sp6zV9zID/5yuS1+5foj0DfuYGdQ6KHj/IbKvyKv1zNX6VCmnKYwtmHadEO6mFc2EKISiu3RDPAvA==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.26", + "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } @@ -14488,9 +14497,9 @@ } }, "node_modules/astro": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/astro/-/astro-6.0.4.tgz", - "integrity": "sha512-1piLJCPTL/x7AMO2cjVFSTFyRqKuC3W8sSEySCt1aJio+p/wGs5H3K+Xr/rE9ftKtknLUtjxCqCE7/0NsXfGpQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.0.5.tgz", + "integrity": "sha512-JnLCwaoCaRXIHuIB8yNztJrd7M3hXrHUMAoQmeXtEBKxRu/738REhaCZ1lapjrS9HlpHsWTu3JUXTERB/0PA7g==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^3.0.0", @@ -15268,9 +15277,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", - "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -16541,13 +16550,13 @@ } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -16577,30 +16586,6 @@ "node": ">=4" } }, - "node_modules/cssstyle": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz", - "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==", - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.2", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.5" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -22651,35 +22636,35 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", + "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.2", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.24.3", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -22690,15 +22675,6 @@ } } }, - "node_modules/jsdom/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/jsdom/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -22711,17 +22687,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/jsdom/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 14" + "node": "20 || >=22" } }, "node_modules/jsdom/node_modules/parse5": { @@ -22737,9 +22709,9 @@ } }, "node_modules/jsdom/node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -22761,9 +22733,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.21.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", - "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -22788,9 +22760,9 @@ } }, "node_modules/jsdom/node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -25907,9 +25879,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "license": "CC0-1.0" }, "node_modules/mdurl": { @@ -27155,9 +27127,9 @@ } }, "node_modules/nanostores": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.1.tgz", - "integrity": "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", "funding": [ { "type": "github", @@ -33381,13 +33353,13 @@ } }, "node_modules/resend": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", - "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz", + "integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==", "license": "MIT", "dependencies": { "postal-mime": "2.7.3", - "svix": "1.84.1" + "svix": "1.86.0" }, "engines": { "node": ">=20" @@ -35829,9 +35801,9 @@ } }, "node_modules/stylelint-order": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-8.0.0.tgz", - "integrity": "sha512-1oAwPRz6Ba8u9LPjgvdbeMjZszHhjY6DBBK+xMlS3IC89GdTsvAPpGYvW+dkn6pxc4ZaI1S959g4c8CftZbhIg==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-8.1.1.tgz", + "integrity": "sha512-LqsEB6VggJuu5v10RtkrQsBObcdwBE7GuAOlwfc/LR3VL/w8UqKX2BOLIjhyGt0Gne/njo7gRNGiJAKhfmPMNw==", "license": "MIT", "dependencies": { "postcss": "^8.5.8", @@ -36057,9 +36029,9 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==" }, "node_modules/svix": { - "version": "1.84.1", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", - "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz", + "integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==", "license": "MIT", "dependencies": { "standardwebhooks": "1.0.0", @@ -36508,21 +36480,21 @@ "license": "MIT" }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz", + "integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==", "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.26", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz", + "integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==", "license": "MIT" }, "node_modules/tldts-icann": { @@ -37019,9 +36991,9 @@ } }, "node_modules/typescript-auto-import-cache/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -37031,15 +37003,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -39817,9 +39789,9 @@ } }, "node_modules/volar-service-css": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.66.tgz", - "integrity": "sha512-XrL1V9LEAHnunglYdDf/7shJbQXqKsHB+P69zPmJTqHx6hqvM9GWNbn2h7M0P/oElW8p/MTVHdfjl6C8cxdsBQ==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", + "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==", "license": "MIT", "dependencies": { "vscode-css-languageservice": "^6.3.0", @@ -39836,12 +39808,12 @@ } }, "node_modules/volar-service-emmet": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.66.tgz", - "integrity": "sha512-BMPSpm6mk0DAEVdI2haxYIOt1Z2oaIZvCGtXuRu95x50a5pOSRPjdeHv2uGp1rQsq1Izigx+VR/bZUf2HcSnVQ==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz", + "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==", "license": "MIT", "dependencies": { - "@emmetio/css-parser": "github:ramya-rao-a/css-parser#vscode", + "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" @@ -39856,9 +39828,9 @@ } }, "node_modules/volar-service-html": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.66.tgz", - "integrity": "sha512-MKKD2qM8qVZvBKBIugt00+Bm8j1ehgeX7Cm5XwgeEgdW/3PhUEEe/aeTxQGon1WJIGf2MM/cHPjZxPJOQN4WfQ==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz", + "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==", "license": "MIT", "dependencies": { "vscode-html-languageservice": "^5.3.0", @@ -39875,9 +39847,9 @@ } }, "node_modules/volar-service-prettier": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.66.tgz", - "integrity": "sha512-CVaQEyfmFWoq3NhNVExoyDKonPqdacmb/07w7OfTZljxLgZpDRygiHAvzBKIcenb7rKtJNHqfQJv99ULOinJBA==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz", + "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8" @@ -39896,9 +39868,9 @@ } }, "node_modules/volar-service-typescript": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.66.tgz", - "integrity": "sha512-8irsfCEf86R1RqPijrU6p5NCqKDNzyJNWKM6ZXmCcJqhebtl7Hr/a0bnlr59AzqkS3Ym4PbbJZs1K/92CXTDsw==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz", + "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==", "license": "MIT", "dependencies": { "path-browserify": "^1.0.1", @@ -39918,9 +39890,9 @@ } }, "node_modules/volar-service-typescript-twoslash-queries": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.66.tgz", - "integrity": "sha512-PA3CyvEaBrkxJcBq+HFdks1TF1oJ8H+jTOTQUurLDRkVjmUFg8bfdya6U/dWfTsPaDSRM4m/2chwgew5zoQXfg==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz", + "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8" @@ -39935,9 +39907,9 @@ } }, "node_modules/volar-service-typescript/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -39947,13 +39919,13 @@ } }, "node_modules/volar-service-yaml": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.66.tgz", - "integrity": "sha512-q6oTKD6EMEu1ws1FDjRw+cfCF69Gu51IEGM9jVbtmSZS1qQHKxMqlt2+wBInKl2D+xILtjzkWbfkjQyBYQMw7g==", + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz", + "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==", "license": "MIT", "dependencies": { "vscode-uri": "^3.0.8", - "yaml-language-server": "~1.19.2" + "yaml-language-server": "~1.20.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" @@ -39965,9 +39937,9 @@ } }, "node_modules/vscode-css-languageservice": { - "version": "6.3.8", - "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.8.tgz", - "integrity": "sha512-dBk/9ullEjIMbfSYAohGpDOisOVU1x2MQHOeU12ohGJQI7+r0PCimBwaa/pWpxl/vH4f7ibrBfxIZY3anGmHKQ==", + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", @@ -39977,9 +39949,9 @@ } }, "node_modules/vscode-html-languageservice": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.0.tgz", - "integrity": "sha512-FIVz83oGw2tBkOr8gQPeiREInnineCKGCz3ZD1Pi6opOuX3nSRkc4y4zLLWsuop+6ttYX//XZCI6SLzGhRzLmA==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", @@ -40986,15 +40958,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yaml-eslint-parser": { @@ -41026,15 +41001,14 @@ } }, "node_modules/yaml-language-server": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.19.2.tgz", - "integrity": "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz", + "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==", "license": "MIT", "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", - "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", @@ -41049,9 +41023,9 @@ } }, "node_modules/yaml-language-server/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -41232,7 +41206,7 @@ "version": "0.0.1", "license": "AGPL-3.0", "engines": { - "node": ">=22.0.0 <23.0.0", + "node": ">=24.0.0 <25.0.0", "npm": ">=10.0.0" } }, @@ -41241,7 +41215,7 @@ "version": "0.0.1", "license": "AGPL-3.0", "engines": { - "node": ">=22.0.0 <23.0.0", + "node": ">=24.0.0 <25.0.0", "npm": ">=10.0.0" } } diff --git a/package.json b/package.json index 61f89cbfb..702c0835d 100644 --- a/package.json +++ b/package.json @@ -67,13 +67,13 @@ }, "dependencies": { "@adobe/remark-gridtables": "^3.0.18", - "@astrojs/check": "0.9.7", + "@astrojs/check": "0.9.8", "@astrojs/db": "^0.20.0", - "@astrojs/mdx": "5.0.0", - "@astrojs/preact": "5.0.0", + "@astrojs/mdx": "5.0.1", + "@astrojs/preact": "5.0.1", "@astrojs/rss": "4.0.17", "@astrojs/sitemap": "^3.7.1", - "@astrojs/vercel": "^10.0.0", + "@astrojs/vercel": "^10.0.1", "@axe-core/playwright": "^4.11.1", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1", "@eslint/js": "10.0.1", @@ -86,7 +86,7 @@ "@nanostores/persistent": "^1.3.3", "@playwright/browser-chromium": "^1.58.2", "@playwright/test": "1.58.2", - "@semantic-ui/astro-lit": "^5.1.2", + "@semantic-ui/astro-lit": "^5.3.0", "@sentry/astro": "^10.43.0", "@sentry/browser": "^10.43.0", "@shikijs/transformers": "^4.0.2", @@ -112,8 +112,8 @@ "@types/react": "^19.2.14", "@types/sanitize-html": "^2.16.1", "@types/to-ico": "1.1.3", - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", "@ungap/structured-clone": "^1.3.0", "@uppy/audio": "^3.1.0", "@uppy/box": "^4.1.0", @@ -129,11 +129,11 @@ "@vitest/coverage-v8": "^4.1.0", "@webcomponents/template-shadowroot": "^0.2.1", "alex": "^11.0.1", - "astro": "6.0.4", + "astro": "6.0.5", "astro-link-validator": "github:rodgtr1/astro-link-validator", "astro-og-canvas": "^0.10.1", "astro-vtbot": "^2.1.12", - "baseline-browser-mapping": "^2.10.7", + "baseline-browser-mapping": "^2.10.8", "canvas-confetti": "^1.9.4", "confusing-browser-globals": "1.0.11", "cross-env": "^10.1.0", @@ -163,14 +163,14 @@ "is-whitespace-character": "^2.0.1", "isomorphic-git": "^1.37.4", "js-cookie": "^3.0.5", - "jsdom": "^28.1.0", + "jsdom": "^29.0.0", "libphonenumber-js": "1.12.40", "lighthouse": "^13.0.3", "lit": "^3.3.2", "markdownlint-cli2": "^0.21.0", "md-attr-parser": "^1.3.0", "mermaid": "^11.13.0", - "nanostores": "^1.1.1", + "nanostores": "^1.2.0", "nodemailer": "^8.0.2", "npm": "^11.11.1", "playwright-lighthouse": "^4.0.0", @@ -203,7 +203,7 @@ "remark-stringify": "^11.0.0", "remark-supersub": "^1.0.0", "remark-video": "^0.10.0", - "resend": "^6.9.3", + "resend": "^6.9.4", "retext": "^9.0.0", "retext-smartypants": "^6.2.0", "rimraf": "6.1.3", @@ -215,7 +215,7 @@ "stylelint": "^17.4.0", "stylelint-config-standard": "^40.0.0", "stylelint-declaration-block-no-ignored-properties": "3.0.0", - "stylelint-order": "8.0.0", + "stylelint-order": "8.1.1", "tailwindcss": "^4.2.1", "temp-dir": "3.0.0", "timezones-ical-library": "^2.1.3", @@ -223,7 +223,7 @@ "to-ico": "1.1.5", "tslib": "2.8.1", "typescript": "5.9.3", - "typescript-eslint": "8.57.0", + "typescript-eslint": "8.57.1", "unified": "^11.0.5", "unist": "^0.0.1", "unist-util-inspect": "^8.1.0", diff --git a/src/components/Icon/icons/git.astro b/src/components/Icon/icons/git.astro new file mode 100644 index 000000000..4b9cacaeb --- /dev/null +++ b/src/components/Icon/icons/git.astro @@ -0,0 +1,29 @@ +--- +export type Props = { + color: string + classes?: string + size: number + accessible: boolean + focusable: boolean + isListMarker?: boolean + id?: string +} + +const { color, classes, size, accessible, focusable, isListMarker, id } = Astro.props +--- + + + {accessible && User Avatar Icon} + + diff --git a/src/components/Icon/icons/turtle.svg b/src/components/Icon/icons/turtle.svg deleted file mode 100644 index 812834651..000000000 --- a/src/components/Icon/icons/turtle.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - diff --git a/src/components/List/__tests__/index.spec.ts b/src/components/List/__tests__/index.spec.ts index dfb8d8794..c34c5ad01 100644 --- a/src/components/List/__tests__/index.spec.ts +++ b/src/components/List/__tests__/index.spec.ts @@ -132,4 +132,104 @@ describe('List (Astro)', () => { expect(lead?.getAttribute('style')).toBeNull() }) }) + + test('applies interactive card styling for the card-grid-list variant', async () => { + const List = (await import('@components/List/index.astro')).default + + const renderedHtml = await container.renderToString(List, { + props: { + variant: 'card-grid-list', + items: [ + { + lead: 'Owner clarity matters.', + text: 'Each card should clearly communicate its primary action.', + }, + ], + }, + }) + + await withJsdomEnvironment(async ({ window }) => { + window.document.body.innerHTML = renderedHtml + + const card = window.document.querySelector('ul li') + const lead = window.document.querySelector('ul li em') + + expect(card).toBeTruthy() + expect(card?.className).toContain('hover:-translate-y-1') + expect(card?.className).toContain('hover:bg-page-base') + expect(card?.className).toContain('hover:shadow-md') + expect(card?.className).toContain('focus-within:border-primary') + expect(lead?.textContent).toContain('Owner clarity matters.') + }) + }) + + test('stacks lead and text vertically for the colored-marker-list variant', async () => { + const List = (await import('@components/List/index.astro')).default + + const renderedHtml = await container.renderToString(List, { + props: { + variant: 'colored-marker-list', + items: [ + { + lead: 'Compatibility adapters', + text: 'Provide a translation layer during migration.', + color: 'bg-success', + }, + ], + }, + }) + + await withJsdomEnvironment(async ({ window }) => { + window.document.body.innerHTML = renderedHtml + + const listItem = window.document.querySelector('ul li') + const marker = window.document.querySelector('ul li > span:first-child') + const contentWrapper = window.document.querySelector('ul li > div') + const lead = window.document.querySelector('ul li em') + const text = window.document.querySelector('ul li div > span') + + expect(listItem).toBeTruthy() + expect(marker?.className).toContain('bg-success') + expect(contentWrapper?.className).toContain('flex-col') + expect(contentWrapper?.className).not.toContain('sm:flex-row') + expect(contentWrapper?.className).toContain('items-start') + expect(contentWrapper?.className).toContain('gap-1') + expect(lead?.className).not.toContain('sm:mr-2') + expect(lead?.textContent).toContain('Compatibility adapters') + expect(text?.textContent).toContain('Provide a translation layer during migration.') + }) + }) + + test('applies hover styling for the three-column-icon-list variant', async () => { + const List = (await import('@components/List/index.astro')).default + + const renderedHtml = await container.renderToString(List, { + props: { + variant: 'three-column-icon-list', + items: [ + { + title: 'Resource metrics', + text: 'Built-in via Metrics Server.', + icon: 'graph', + color: 'page-inverse', + bgColor: 'info-inverse', + }, + ], + }, + }) + + await withJsdomEnvironment(async ({ window }) => { + window.document.body.innerHTML = renderedHtml + + const card = window.document.querySelector('ul li') + const title = window.document.querySelector('ul li h3') + + expect(card).toBeTruthy() + expect(card?.className).toContain('transition-all') + expect(card?.className).toContain('duration-200') + expect(card?.className).toContain('hover:-translate-y-1') + expect(card?.className).toContain('hover:shadow-lg') + expect(title?.textContent).toContain('Resource metrics') + }) + }) }) \ No newline at end of file diff --git a/src/components/List/layouts/CardGridList.astro b/src/components/List/layouts/CardGridList.astro index ce3b9cce9..5e27765cf 100644 --- a/src/components/List/layouts/CardGridList.astro +++ b/src/components/List/layouts/CardGridList.astro @@ -17,7 +17,10 @@ export type Props = { const { items, classes }: Props = Astro.props const ulClass = ["grid grid-cols-1 md:grid-cols-2 gap-4", classes?.ul] -const liClass = ["bg-page-offset border border-trim rounded-lg p-5 hover:border-primary transition-colors", classes?.li] +const liClass = [ + "bg-page-offset border border-trim rounded-lg p-5 transition-all duration-200 ease-out hover:-translate-y-1 hover:border-primary hover:bg-page-base hover:shadow-md focus-within:-translate-y-1 focus-within:border-primary focus-within:bg-page-base focus-within:shadow-md", + classes?.li, +] const emClass = ["block text-page-inverse font-semibold not-italic mb-2", classes?.em] --- diff --git a/src/components/List/layouts/ColoredMarkerList.astro b/src/components/List/layouts/ColoredMarkerList.astro index f9f68c283..bcea5ede6 100644 --- a/src/components/List/layouts/ColoredMarkerList.astro +++ b/src/components/List/layouts/ColoredMarkerList.astro @@ -19,7 +19,7 @@ const { items, classes, size = 2 }: Props = Astro.props const ulClass = ["list-none pl-0 text-content mt-4 mb-2 space-y-2", classes?.ul] const liClass = ["flex items-start", classes?.li] -const emClass = ["mr-0 sm:mr-2 not-italic font-bold", classes?.em] +const emClass = ["not-italic font-bold", classes?.em] const markerSizeInRem = `${size * 0.25}rem` const markerStyle = `width: ${markerSizeInRem}; height: ${markerSizeInRem};` const iconClass = ["rounded-full mt-2 mr-3 shrink-0", classes?.icon] @@ -31,7 +31,7 @@ const textClass = classes?.text ? [classes.text] : [] items.map((item) => (
  • -
    +
    {item.lead && }
    diff --git a/src/components/List/layouts/ThreeColumnIconList.astro b/src/components/List/layouts/ThreeColumnIconList.astro index fd8e5e793..a7543d4ee 100644 --- a/src/components/List/layouts/ThreeColumnIconList.astro +++ b/src/components/List/layouts/ThreeColumnIconList.astro @@ -25,7 +25,7 @@ export type Props = { const { items, classes, size }: Props = Astro.props const ulClass = ["grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6", classes?.ul].filter(Boolean).join(" ") -const liClass = ["flex items-start gap-2 bg-white rounded-xl p-6 shadow-md", classes?.li].filter(Boolean).join(" ") +const liClass = ["flex items-start gap-2 bg-white rounded-xl p-6 shadow-md transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-lg", classes?.li].filter(Boolean).join(" ") const iconWrapper = ["shrink-0 rounded-lg flex items-center justify-center mt-1 p-2", classes?.icon].filter(Boolean).join(" ") const contentWrapper = ["text-page-inverse mb-1 ml-2", classes?.content].filter(Boolean).join(" ") const headerClass = ["font-sans text-lg font-semibold text-gray-900 mt-0 mb-2", classes?.header].filter(Boolean).join(" ") diff --git a/src/components/List/layouts/TimelineList.astro b/src/components/List/layouts/TimelineList.astro index ca71eb2d3..be9de5e76 100644 --- a/src/components/List/layouts/TimelineList.astro +++ b/src/components/List/layouts/TimelineList.astro @@ -8,6 +8,7 @@ export type Props = { ul?: string li?: string em?: string + icon?: string } } @@ -24,7 +25,7 @@ const ulClass = ["space-y-0", classes?.ul] const liClass = ["relative grid grid-cols-[1.5rem_1fr] gap-4", classes?.li] const emClass = ["block text-content font-bold not-italic mb-1", classes?.em] const wrapperClass = "mb-4" -const iconClass = ["relative h-5 w-5 mt-0.5 rounded-full border-2 border-primary bg-page-base z-10"] +const iconClass = ["relative h-5 w-5 mt-0.5 rounded-full border-2 border-primary bg-page-base z-10", classes?.icon] const connectorClass = ["absolute left-1/2 top-[1.375rem] -bottom-1 -translate-x-1/2 w-0.5 bg-content-offset"] --- diff --git a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx index c746cf138..04c0ab3d8 100644 --- a/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx +++ b/src/content/articles/golden-paths-developer-experience-standardization-autonomy/index.mdx @@ -30,10 +30,13 @@ There's a better way. Golden paths offer curated, supported ways to accomplish c ## What Makes a Path Golden -A golden path is a supported, well-documented way to accomplish a common task that the platform team actively maintains and improves. That definition has important implications: +A golden path is a supported, well-documented way to accomplish a common task that the platform team actively maintains and improves. That definition has important implications:
  • +Beyond the clutter, dashboards aren't free. Every panel issues queries to your backend — Prometheus, InfluxDB, your SQL warehouse. A dashboard with 20 panels viewed by 50 people triggers 1,000 queries every refresh cycle. Grafana-managed alerts linked to dashboard panels run continuously, checking for firing conditions even when nobody's watching. That DEBUG dashboard from last quarter? Its alert rules are still querying your metrics database every minute. + ## Measuring Dashboard Health You can't make evidence-based decisions about what to keep or delete without usage data. The first step in any hygiene program is instrumenting your Grafana instance to track who's looking at what. @@ -100,7 +103,10 @@ You can't make evidence-based decisions about what to keep or delete without usa For each dashboard, you need four categories of metrics: Beyond the numeric score, flag dashboards with specific problems: "no-recent-views," "stale," "no-owner," "broken-panels," "unmaintained," "likely-clone" (title contains "copy"), or "incident-artifact" (title contains "debug"). -These flags help prioritize review. A dashboard scoring 40 with "broken-panels" needs immediate attention — it's actively misleading anyone who looks at it. A dashboard scoring 40 with "no-recent-views" can wait for the next cleanup cycle. - Automate usage tracking from day one. Without data, hygiene decisions become political debates instead of evidence-based choices. "Nobody uses this dashboard" is much more persuasive when you can prove it with numbers. +These flags help prioritize review. A dashboard scoring 40 with "broken-panels" needs immediate attention — it's actively misleading anyone who looks at it. A dashboard scoring 40 with "no-recent-views" can wait for the next cleanup cycle. + ## Making Cleanup Stick Process alone won't fix dashboard rot. People hoard dashboards because deletion feels risky and there's no reward for tidiness. You need to change the incentives — and have responses ready for the inevitable objections. -If you do nothing else: implement usage tracking and run one cleanup campaign. Those two actions will give you the data to make informed decisions and the momentum to build a sustainable hygiene practice. - The goal isn't the smallest possible dashboard count. It's ensuring that every dashboard that exists is accurate, maintained, and serves a clear purpose. When an engineer searches for "payments" during an incident, they should find 3 relevant dashboards, not 30 stale ones. diff --git a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx index 65d732aa5..b07eee86c 100644 --- a/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx +++ b/src/content/articles/grafana-dashboard-hygiene-pruning-actionable-metrics/pdf.mdx @@ -33,7 +33,10 @@ Every dashboard you create is a promise to maintain it. Dashboards without owner Dashboard accumulation isn't anyone's fault — it's a natural consequence of how teams work. Every dashboard enters your inventory through a legitimate path, and almost none ever leave. +Each of these patterns has a different expected lifecycle, but they all share the same outcome: permanent residency in your Grafana instance. Understanding the intended lifespan of each type is key to building cleanup policies that make sense — you wouldn't apply the same retention rules to a service overview dashboard and an incident debug dashboard. +
    @@ -197,7 +213,10 @@ Grafana stores dashboard definitions in its database by default — SQLite for s The alternative is treating dashboards as code. Instead of editing in the browser, you define dashboards in a repository and deploy them through CI/CD. Always archive before deleting. A 90-day cold storage period catches the "but we need that during quarterly reviews!" case. Export the JSON, store it somewhere searchable, and only permanently delete after the retention period with no claims. +This creates accountability. Dashboard owners either maintain their dashboards or lose them. The system is predictable and fair — everyone knows the rules, and the automation applies those rules consistently. + ## Actionable Dashboard Design Cleaning up existing dashboards is only half the battle. Without standards for __new__ dashboards, you'll be right back where you started in a year. The goal is dashboards that drive action, not dashboards that display data. @@ -285,7 +310,10 @@ Cleaning up existing dashboards is only half the battle. Without standards for _ Before creating or keeping a dashboard, it should pass these tests:
    - -What I learned is that the rollback wasn't the problem - the problem started days or weeks earlier when the cluster state quietly drifted away from what Helm thought it was managing. This article explains where that drift comes from, how to catch it before it becomes an incident, and what to do when you're staring at a failed release at 3 AM. @@ -45,7 +45,7 @@ The problem is that Helm only knows about changes made __through Helm__. Any mod Drift doesn't usually happen through malice. It happens through the normal operations of running production systems: -The key practices are straightforward: run `helm diff` before every upgrade (automate this in CI), schedule regular drift detection across your fleet, and when failures happen, follow a systematic diagnosis workflow instead of making changes blindly. - For teams running dozens or hundreds of Helm releases, GitOps tools like Flux and Argo CD can enforce desired state continuously, but they come with their own complexity. The fundamentals of understanding Helm state and detecting drift remain essential regardless of what tooling you use. diff --git a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx index c9c75b10e..c1c7f8ba8 100644 --- a/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx +++ b/src/content/articles/helm-release-management-drift-detection-debugging/pdf.mdx @@ -12,14 +12,12 @@ featured: true import helmReleaseFailureDiagram from "./diagrams/helm-release-failure-diagnosis-workflow.jpg" import helmReleaseStateDiagram from "./diagrams/helm-release-state-and-drift-flow.jpg" -*[YAML]: YAML Ain't Markup Language -*[K8s]: Kubernetes +*[ConfigMap]: Kubernetes ConfigMap resource *[CRD]: Custom Resource Definition -*[CI]: Continuous Integration -*[CD]: Continuous Deployment +*[K8s]: Kubernetes *[OCI]: Open Container Initiative *[RBAC]: Role-Based Access Control -*[ConfigMap]: Kubernetes ConfigMap resource +*[YAML]: YAML Ain't Markup Language Helm makes deploying applications to Kubernetes straightforward. Write a chart, run `helm install`, and your app materializes in the cluster. But that simplicity doesn't scale. When you're managing dozens of services across multiple clusters, you lose track of which chart versions are deployed where, which releases are drifting from their declared state, and why that upgrade failed at 2 AM. @@ -27,12 +25,12 @@ I've spent time untangling Helm state in production clusters, and the pattern is Here's a scenario I've seen more than once. During an incident, an engineer tries to rollback a Helm release to a known-good state. The rollback fails. Turns out someone ran `kubectl edit` on the deployment three weeks ago to bump memory limits. Helm's stored manifest doesn't match what's in the cluster, so the three-way merge produces unexpected results. The team spends 45 minutes reconciling state instead of fixing the actual problem. -This article covers the operational side of Helm at scale: understanding how Helm tracks state, detecting drift before it causes incidents, maintaining a release inventory across clusters, debugging failed releases systematically, and integrating with GitOps tools for continuous reconciliation. - Helm tracks its own state, but the cluster is the source of truth. When these diverge — through manual edits, partial upgrades, or failed releases — Helm's model breaks down. At scale, drift isn't __if__, it's __when__. +This article covers the operational side of Helm at scale: understanding how Helm tracks state, detecting drift before it causes incidents, maintaining a release inventory across clusters, debugging failed releases systematically, and integrating with GitOps tools for continuous reconciliation. + ## Understanding Helm State Before you can fix drift, you need to understand how Helm tracks what it's deployed. This isn't just academic — when things break, knowing where Helm stores its state and how it compares against the cluster determines whether you spend 5 minutes or 50 minutes recovering. @@ -43,6 +41,7 @@ When you run `helm upgrade`, Helm doesn't just push resources to the cluster and @@ -73,35 +72,11 @@ The critical thing to understand: Helm's stored manifest represents what Helm __ Drift shows up in predictable patterns, and recognizing which pattern you're dealing with speeds up recovery. - -
    +kubectl edit deployment to bump resource limits during an incident, or kubectl patch to add an annotation. The cluster state changes, but Helm doesn\'t know. The next helm upgrade may revert those changes unexpectedly, or the three-way merge may produce surprising results.', + }, + { + lead: "Partial upgrade failures", + text: 'These leave you in limbo. If Helm times out or hits an error mid-upgrade, some resources may be updated while others aren\'t. You\'ll see pods running different versions, ConfigMaps updated but Deployments not, and a release status of "failed" that blocks further operations.', + }, + { + lead: "Secret storage corruption", + text: 'Less common but more painful. If cleanup scripts, namespace recreation, or manual deletion removes Helm\'s release secrets, the cluster still has the resources but Helm has no record of them. helm list shows nothing, but helm install fails with "already exists."', + }, + { + lead: "Hook failures", + text: "Creates orphaned state. Pre-upgrade or post-upgrade hooks that fail can leave partial resources. The release is marked failed, but the hook's job or pod may still exist, and main resources may or may not have been deployed depending on when the hook failed.", + }, + { + lead: "Three-way merge conflicts", + text: "The trickiest. When Helm computes an upgrade, it compares the old manifest, new manifest, and live cluster state. If someone modified a field in the cluster that you're also changing in the new manifest, the merge can produce unexpected results — fields deleted, values unexpectedly retained, or strategic merge patches behaving counter-intuitively.", + }, + ]} +/> + Helm uses a three-way merge: old manifest, new manifest, live state. This usually works well, but manual changes to live state can create surprising merge results. When in doubt, use `--force` for a two-way merge (but understand the implications — it recreates resources rather than patching them). @@ -262,16 +263,22 @@ helm list --all-namespaces --output json The output includes release name, namespace, chart name and version, app version, status, and last deployment timestamp. Aggregate this across clusters and you can answer questions like: @@ -318,7 +325,8 @@ groups: The version inconsistency alert is particularly useful during rollouts. If you're upgrading a chart across clusters, you can see at a glance which clusters are done and which are still pending.
    @@ -469,7 +482,10 @@ helm template my-release ./my-chart -f values.yaml | kubectl apply --dry-run=ser ### Common Failure Patterns order:12345:payment:v3. This approach has semantic meaning, making debugging easier, but requires careful thought about what constitutes a unique operation.', }, { lead: "Queue message ID", - text: "- avoid this approach. SQS assigns a new MessageId on each redelivery. RabbitMQ's delivery tag changes. This approach only deduplicates within a single delivery attempt, which defeats the purpose entirely.", + text: 'Avoid this approach. SQS assigns a new MessageId on each redelivery. RabbitMQ\'s delivery tag changes. This approach only deduplicates within a single delivery attempt, which defeats the purpose entirely.', }, ]} /> @@ -74,10 +77,10 @@ Once you have a stable idempotency key, you need somewhere to store the record o A dedicated idempotency store sits between your handler and your business logic. Before processing, the handler checks the store. If the key exists and shows "completed," return the cached result. If the key exists and shows "processing," another instance is handling it - either wait or fail fast. If the key doesn't exist, acquire a lock and proceed. -Redis works well for high-throughput scenarios where you need sub-millisecond latency. The critical detail is the `NX` flag on the SET command - it only sets the key if it doesn't already exist, making the check-and-lock operation atomic. - +Redis works well for high-throughput scenarios where you need sub-millisecond latency. The critical detail is the `NX` flag on the SET command - it only sets the key if it doesn't already exist, making the check-and-lock operation atomic. + ### Database-Level Deduplication For financial operations or anywhere you need transactional consistency, database-level deduplication is often better than a separate store. The idempotency key becomes a unique constraint on your business table. @@ -112,6 +115,7 @@ The sequence diagram below shows the full flow. Notice that on the second delive @@ -119,7 +123,8 @@ The sequence diagram below shows the full flow. Notice that on the second delive Which storage should you use? It depends on your consistency requirements and latency budget:
    "At-least-once" means "probably more than once." Design every handler assuming the message has already been processed. The question isn't __if__ duplicates arrive - it's when and how often. +This article walks through the patterns that make handlers survive retries: understanding delivery guarantees, designing idempotency keys, implementing deduplication stores, and building handlers that produce the same result regardless of how many times they receive the same message. + ## Delivery Guarantees Before diving into implementation, it's worth understanding why duplicates are inevitable. Message queues offer three delivery semantics, and the tradeoffs between them explain why at-least-once is the default for anything that matters. @@ -41,7 +41,10 @@ Before diving into implementation, it's worth understanding why duplicates are i ### Understanding Message Delivery Semantics order:12345:payment:v3. This approach has semantic meaning, making debugging easier, but requires careful thought about what constitutes a unique operation. Does a payment retry get a new version, or should it reuse the same key?', }, { lead: "Queue message ID", - text: "- don't use this. SQS assigns a new MessageId on each redelivery. RabbitMQ's delivery tag changes. This approach only deduplicates within a single delivery attempt, which defeats the purpose entirely.", + text: "Don't use this. SQS assigns a new MessageId on each redelivery. RabbitMQ's delivery tag changes. This approach only deduplicates within a single delivery attempt, which defeats the purpose entirely.", }, ]} /> @@ -152,19 +159,19 @@ Code: Recommended idempotency key pattern combining producer ID with business co Beyond the key format, you need to decide how long to remember processed keys and at what scope. @@ -271,6 +278,7 @@ The sequence diagram below shows the full flow. Notice that on the second delive @@ -278,7 +286,7 @@ The sequence diagram below shows the full flow. Notice that on the second delive Which storage should you use? It depends on your consistency requirements and latency budget:
    user.email = \'new@example.com\' produces the same state whether you run it once or ten times. Same with UPDATE users SET email = $1 WHERE id = $2.', }, { lead: "Upserts", - text: "are naturally idempotent. `INSERT ... ON CONFLICT UPDATE` converges to the same final state regardless of how many times you execute it.", + text: 'Naturally idempotent. INSERT ... ON CONFLICT UPDATE converges to the same final state regardless of how many times you execute it.', }, { lead: "Deletes by ID", - text: "are naturally idempotent. `DELETE FROM orders WHERE id = 123` succeeds once and becomes a no-op on subsequent executions.", + text: 'Naturally idempotent. DELETE FROM orders WHERE id = 123 succeeds once and becomes a no-op on subsequent executions.', }, { lead: "Absolute state sets", - text: "are naturally idempotent. `inventory.setQuantity(sku, 5)` sets the quantity to 5, not \"adds 5.\" Running it twice still leaves the quantity at 5.", + text: 'Naturally idempotent. inventory.setQuantity(sku, 5) sets the quantity to 5, not "adds 5." Running it twice still leaves the quantity at 5.', }, ]} /> ### Operations That Require Deduplication -The operations that __aren't__ naturally idempotent are the ones that accumulate: +The operations that _aren't_ naturally idempotent are the ones that accumulate: balance += 100 adds 100 every time. The fix is to track which operations have been applied: applyDeposit(depositId, 100) can check whether depositId was already applied.', }, { lead: "Appends", - text: "grow the collection. `items.push(newItem)` adds another entry each time. The fix is to include an item ID and check before appending: `if (!items.some(i => i.id === newItem.id)) items.push(newItem)`.", + text: 'Grow the collection. items.push(newItem) adds another entry each time. The fix is to include an item ID and check before appending: if (!items.some(i => i.id === newItem.id)) items.push(newItem).', }, { lead: "External side effects", - text: "like sending emails or notifications aren't naturally idempotent. The user receives the email on each execution. Track sent notifications by key to prevent duplicates.", + text: "Like sending emails or notifications aren't naturally idempotent. The user receives the email on each execution. Track sent notifications by key to prevent duplicates.", }, ]} /> @@ -429,6 +440,7 @@ This guarantees that if the state change commits, the event will eventually be p @@ -730,7 +742,8 @@ Code: Kafka idempotent consumer with database-backed deduplication. The table below summarizes what each major queue provides out of the box. The pattern is consistent: every queue requires handler-side idempotency for production workloads. Built-in deduplication helps at the margins but doesn't eliminate the need for application-level duplicate detection.
    The question isn't whether you _can_ build a wrapper - it's whether you _should_. And if you do, how do you build it in a way that doesn't become a liability? ## When Wrappers Genuinely Add Value Not all wrappers are created equal. Some genuinely earn their maintenance cost; others are solutions looking for problems. Here's what separates the valuable from the wasteful: @@ -54,7 +57,8 @@ Some "problems" have simpler solutions. Alias collections - just shorter names f Before building, ask these questions honestly:
    + The transparent wrapper pattern treats the underlying tool as the source of truth. The wrapper adds hooks for context injection, guard rails, and logging, but everything it doesn't explicitly handle passes through unchanged. Unknown flags? Pass them through. New subcommands? Pass them through. The wrapper should __never__ be the reason a valid command fails. @@ -99,15 +105,13 @@ Two features are essential. First, `--wrapper-debug` shows exactly what command Notice what a transparent wrapper __doesn't__ do: it doesn't parse kubectl's output, it doesn't assume specific flag formats, and it doesn't try to interpret what the user is doing beyond the minimum needed for guard rails. This restraint is what makes it maintainable. When kubectl adds a new flag in version 1.32, the wrapper doesn't need to change - the flag passes through automatically. - - ### Context Injection Done Right Earlier I mentioned context injection as a legitimate use case - auto-selecting environment configs based on git branch or directory. But this is also where wrappers most often go wrong. Remember the `tf-deploy` incident? The rule is simple: __always show the injected context, never hide it__. A kubectl wrapper that silently switches to production because you're on the `main` branch is a disaster waiting to happen. A wrapper that __shows__ you it detected production context and asks for confirmation? That's genuinely useful. -```text title="context-display-example.txt" +```bash ┌─────────────────────────────────────┐ │ Environment: production │ │ Cluster: prod-us-east-1 │ @@ -120,22 +124,30 @@ Output: Context display before destructive operations. The detection hierarchy should be predictable and documented: --context=prod', }, { - text: "Environment variables (`DEPLOY_ENV=staging`)", + lead: 'Environment variables', + text: 'DEPLOY_ENV=staging', }, { - text: "Git branch detection (`main` → production)", + lead: 'Git branch detection', + text: 'main → production', }, { - text: "Directory structure (`/environments/dev/`)", + lead: 'Directory structure', + text: '/environments/dev/', }, { - text: "Default fallback (usually dev)", + lead: 'Default fallback', + text: 'Usually dev', }, ]} /> @@ -147,27 +159,30 @@ And when context is injected automatically, don't override explicit user flags - Wrappers often fail gradually. Usage stays high because it's in deployment scripts, but developers increasingly bypass it for anything complex. Here's how to catch the decline early: @@ -177,7 +192,8 @@ Wrappers often fail gradually. Usage stays high because it's in deployment scrip Set up tracking before problems become crises:
    The bar for building a wrapper CLI should be high. Every abstraction layer you add is a maintenance commitment. If the underlying tool's UX is good enough with documentation and templates, don't wrap it - improve the docs instead. +I've seen this cycle repeat across multiple organizations. The question isn't whether you _can_ build a wrapper - it's whether you _should_, and if you do, how to build it in a way that doesn't become a liability. This article covers when wrappers genuinely add value, how to design them for transparency and maintainability, the real cost of keeping them healthy, and how to recognize when it's time to deprecate. + ## When Wrappers Add Value Not all wrappers are created equal. Some genuinely earn their maintenance cost; others are solutions looking for problems. Here's what separates the valuable from the wasteful: deploy-service command that builds, pushes, runs helm upgrade, and executes smoke tests in the right order prevents the "I forgot to push the image before deploying" class of errors. The key criterion: errors in the sequence cause significant problems, not just minor inconvenience.', }, { lead: "Guard rails", - text: "prevent dangerous operations or enforce policies. A `kubectl-safe` wrapper that blocks `delete` in production without an approval ticket prevents the kind of outages that make the news. This works when the underlying tool allows dangerous operations with severe consequences and the policy can be codified clearly. Don't create security theater - if you can't actually prevent the dangerous operation, don't pretend you can.", + text: 'Prevent dangerous operations or enforce policies. A kubectl-safe wrapper that blocks delete in production without an approval ticket prevents the kind of outages that make the news. This works when the underlying tool allows dangerous operations with severe consequences and the policy can be codified clearly. Don\'t create security theater - if you can\'t actually prevent the dangerous operation, don\'t pretend you can.', }, { lead: "Context injection", - text: "automatically selects environment-specific configuration. A terraform wrapper that auto-selects workspace, backend, and var files based on your git branch or directory eliminates \"I thought I was in staging\" mistakes. This is valuable when you have multiple environments with different configs and mixing them up causes real problems - data corruption, customer impact, compliance violations.", + text: 'Automatically selects environment-specific configuration. A terraform wrapper that auto-selects workspace, backend, and var files based on your git branch or directory eliminates "I thought I was in staging" mistakes. This is valuable when you have multiple environments with different configs and mixing them up causes real problems - data corruption, customer impact, compliance violations.', }, { lead: "Credential management", - text: "handles authentication complexity transparently. A kubectl wrapper that auto-refreshes tokens and selects the correct cluster config reduces auth friction. This makes sense when auth involves multiple systems, tokens expire frequently, and manual credential management is error-prone.", + text: 'Handles authentication complexity transparently. A kubectl wrapper that auto-refreshes tokens and selects the correct cluster config reduces auth friction. This makes sense when auth involves multiple systems, tokens expire frequently, and manual credential management is error-prone.', }, { lead: "Audit logging", - text: "captures all operations for compliance and debugging. A terraform wrapper that logs who ran what commands when helps with incident investigation and change tracking. Worth building when audit requirements exist and the underlying tool doesn't provide adequate logging.", + text: 'Captures all operations for compliance and debugging. A terraform wrapper that logs who ran what commands when helps with incident investigation and change tracking. Worth building when audit requirements exist and the underlying tool doesn\'t provide adequate logging.', }, ]} /> @@ -64,19 +61,19 @@ Not all wrappers are created equal. Some genuinely earn their maintenance cost; Some "wrappers" solve problems that have simpler solutions: k instead of kubectl is a shell alias, not a wrapper.', }, { lead: "Hardcoded flag defaults", - text: "are better handled by config files or environment variables. A wrapper that always sets `--replicas=3` is inflexible and hides what's happening.", + text: 'Better handled by config files or environment variables. A wrapper that always sets --replicas=3 is inflexible and hides what\'s happening.', }, { lead: "Output formatting", - text: "is a solved problem. Pretty-printing kubectl output? Use `kubectl` plugins, `jq`, or the many existing tools in the ecosystem.", + text: 'A solved problem. Pretty-printing kubectl output? Use kubectl plugins, jq, or the many existing tools in the ecosystem.', }, ]} /> @@ -86,7 +83,11 @@ Some "wrappers" solve problems that have simpler solutions: Before you start writing code, run through these questions honestly. Each "no" is a signal that the simpler path - better docs, shell aliases, or lightweight scripts - will serve you better than a full wrapper. Each "yes" adds weight to the case for building, but also adds to your maintenance commitment.
    --all flags, it becomes too easy to delete more than intended', }, { lead: "Direct edits", - text: "to deployments, statefulsets, or daemonsets - causes drift from GitOps state", + text: 'To deployments, statefulsets, or daemonsets causes drift from GitOps state', }, { lead: "Interactive shells", - text: "in production without audit logging - compliance nightmare", + text: "In production without audit logging are a compliance nightmare", }, { lead: "Scaling to zero", - text: "- blocks all traffic, and developers sometimes do this thinking it's a \"safe\" way to test", + text: "Blocks all traffic, and developers sometimes do this thinking it's a \"safe\" way to test", }, ]} /> @@ -394,23 +398,26 @@ The most valuable guard rails prevent operations that are both __easy to trigger For terraform, the high-risk operations are: rm, mv, push) - can corrupt state and cause resource orphaning', }, { lead: "Import", - text: "without review - can corrupt state if done wrong", + text: "Without review - can corrupt state if done wrong", }, ]} /> @@ -425,25 +432,28 @@ Every blocked operation needs an override. The override should: --force` flag, but something that makes the user stop and think', }, { lead: "Create an audit trail", - text: "- log who overrode the rule, when, and why", + text: "Log who overrode the rule, when, and why", }, { lead: "Reference external authorization", - text: "- a ticket number, a change management ID, or a reviewer's approval", + text: "A ticket number, a change management ID, or a reviewer's approval", }, ]} /> A good block message looks like this: -```text +```bash BLOCKED: Namespace deletion requires manual approval Command: kubectl delete namespace payments @@ -453,14 +463,20 @@ To proceed: Use --force-delete with ticket number Example: kube delete namespace payments --force-delete=TICKET-123 ``` -The user knows __why__ it's blocked, __how__ to proceed if necessary, and the ticket number creates accountability. +Code: Example guard rail block message with override instructions. + +The user knows _why_ it's blocked, _how_ to proceed if necessary, and the ticket number creates accountability. ### Environment-Specific Rules Not every guard rail applies everywhere. Blocking `terraform destroy` in production makes sense; blocking it in dev is just friction. Design your rules with environment awareness:
    kube get pods -o jsonpath=\'{ .items[*].metadata.name }\' passes through exactly as written.', }, { lead: "Guard rail accuracy", - text: "- Your blocked patterns should match what you intend to block, no more, no less. Test both positive cases (this should be blocked) and negative cases (this should be allowed).", + text: "Your blocked patterns should match what you intend to block, no more, no less. Test both positive cases (this should be blocked) and negative cases (this should be allowed).", }, ]} /> @@ -546,7 +562,8 @@ Run this test matrix in CI on every PR, and run it against new upstream versions Be honest about what you're signing up for:
    pip install kube-wrapper --index-url https://pypi.internal.company.com/simple. This works well for Python shops and handles dependencies automatically.', }, { lead: "Homebrew tap", - text: "- For macOS and Linux users, a private Homebrew tap provides a familiar installation experience. Create a tap repository with a formula that downloads your release tarball:", + text: "For macOS and Linux users, a private Homebrew tap provides a familiar installation experience. Create a tap repository with a formula that downloads your release tarball:", }, ]} /> @@ -703,7 +720,7 @@ end ``` kubectl / terraform. Users run docker run company/kube-wrapper get pods.', }, ]} /> @@ -743,7 +760,7 @@ Here's how to recognize the warning signs early. ### Red Flags whyyou're deprecating, not just when.", }, ]} /> -```text +```bash ╔══════════════════════════════════════════════════════════════════╗ ║ DEPRECATION NOTICE: kube wrapper will be retired in 90 days ║ ║ ║ @@ -841,9 +859,11 @@ When it's time to sunset a wrapper, do it gracefully. A sudden removal leaves us ║ Questions: #platform-support ║ ╚══════════════════════════════════════════════════════════════════╝ ``` +Code: Example deprecation notice shown on wrapper invocation. --use-wrapper. Most users will have migrated by now; this phase catches the stragglers and forces anyone still dependent to acknowledge it.', }, { lead: "Phase 4: Removal.", @@ -863,7 +883,8 @@ When it's time to sunset a wrapper, do it gracefully. A sudden removal leaves us The migration guide is the most important artifact. For each wrapper feature, document the native alternative:
    kubectl config use-context or kubie'], }, { th: "Production confirmation", @@ -885,7 +906,7 @@ The migration guide is the most important artifact. For each wrapper feature, do }, { th: "Command shortcuts", - td: ["Shell aliases in ~/.bashrc or ~/.zshrc"], + td: ['Shell aliases in ~/.bashrc or ~/.zshrc'], }, ], }, @@ -904,8 +925,8 @@ Most internal CLI wrappers shouldn't exist. Before building one, exhaust the alt But when wrappers __are__ worth building - for genuine complexity hiding, meaningful guard rails, or critical context injection - build them well. Design for transparency: pass through unknown flags, show underlying commands, provide bypass modes. Commit to ongoing maintenance. Monitor adoption and bypass rates to catch problems early. And build with eventual deprecation in mind - the best outcome is retirement because the ecosystem caught up or your team outgrew the need. -The goal isn't to build a wrapper that lasts forever. It's to build one that serves its purpose, helps your team work safely, and steps aside gracefully when it's no longer needed. - A successful wrapper eventually gets deprecated - not because it failed, but because users learned the underlying tool and the ecosystem caught up. Build wrappers with their eventual retirement in mind. The goal is empowerment, not dependence. + +The goal isn't to build a wrapper that lasts forever. It's to build one that serves its purpose, helps your team work safely, and steps aside gracefully when it's no longer needed. diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx index 228eb355f..8449ba987 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/index.mdx @@ -16,9 +16,9 @@ Last month I watched a developer spend three days getting a PostgreSQL database. And yet the outcome was identical to not having a portal at all - the same tickets, the same meetings, the same waiting. -That's the portal-versus-platform distinction that trips up most organizations. A portal __indexes__ things: it aggregates links, surfaces documentation, shows who owns what. A platform __does__ things: it provisions infrastructure, creates repositories, grants access. The portal tells you how to request a database. The platform gives you a database. +That's the portal-versus-platform distinction that trips up most organizations. A portal _indexes_ things: it aggregates links, surfaces documentation, shows who owns what. A platform _does_ things: it provisions infrastructure, creates repositories, grants access. The portal tells you how to request a database. The platform gives you a database. -Self-service actions are what transform a directory into a platform. Without them, you've built a prettier wiki. +Self-service actions are what transform a directory into a platform. Without them, you've built a prettier wiki. If your "platform" requires developers to read documentation and file tickets to get things done, you've built a directory, not a platform. Platforms execute workflows. Directories point to workflows that humans execute. @@ -29,7 +29,10 @@ If your "platform" requires developers to read documentation and file tickets to Most internal developer portals evolve through predictable stages. Understanding where you are helps clarify where to invest next - and where most organizations get stuck.
    diff --git a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx index 983905c6b..4880eeca9 100644 --- a/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx +++ b/src/content/articles/internal-developer-portal-platform-self-service-actions/pdf.mdx @@ -12,30 +12,33 @@ featured: true import multiStepDiagram from "./diagrams/multi-step-service-creation-workflow.jpg" *[IDP]: Internal Developer Portal/Platform -*[RBAC]: Role-Based Access Control -*[SSO]: Single Sign-On *[K8s]: Kubernetes *[PR]: Pull Request +*[RBAC]: Role-Based Access Control *[SLA]: Service Level Agreement +*[SSO]: Single Sign-On Last month I watched a developer spend three days getting a PostgreSQL database. The company had a beautiful internal developer portal - Backstage, fully themed, comprehensive service catalog, scorecards showing tech debt metrics. The developer found the "Database Provisioning" page in under 30 seconds. Then he read the instructions: file a Jira ticket with the DBA team, wait for triage, attend a 15-minute review meeting to justify the request, and receive credentials via a separate ticket once provisioning completed. Three to five business days, assuming no holidays or sick leave on the DBA team. The portal worked perfectly. The developer knew exactly where to go and what to do. And yet the outcome was identical to not having a portal at all - the same tickets, the same meetings, the same waiting. -That's the portal-versus-platform distinction that trips up most organizations. A portal __indexes__ things: it aggregates links, surfaces documentation, shows who owns what. A platform __does__ things: it provisions infrastructure, creates repositories, grants access. The portal tells you how to request a database. The platform gives you a database. - -The difference matters because it determines whether your internal developer platform actually reduces toil or just makes toil easier to find. Self-service actions are what transform a directory into a platform. Without them, you've built a prettier wiki. +That's the portal-versus-platform distinction that trips up most organizations. A portal _indexes_ things: it aggregates links, surfaces documentation, shows who owns what. A platform _does_ things: it provisions infrastructure, creates repositories, grants access. The portal tells you how to request a database. The platform gives you a database. If your "platform" requires developers to read documentation and file tickets to get things done, you've built a directory, not a platform. Platforms execute workflows. Directories point to workflows that humans execute. +The difference matters because it determines whether your internal developer platform actually reduces toil or just makes toil easier to find. Self-service actions are what transform a directory into a platform. Without them, you've built a prettier wiki. + ## The Portal Maturity Model Most internal developer portals evolve through predictable stages. Understanding where you are helps clarify where to invest next - and where most organizations get stuck.
    @@ -251,27 +259,31 @@ The solution is __policy-based approval routing__: define rules that auto-approv Different situations warrant different approval requirements. Here are the dimensions that typically matter: @@ -281,7 +293,7 @@ These dimensions combine. A production database deletion during change freeze mi In Backstage, you'd implement these rules through a custom approval plugin or integrate with Open Policy Agent (OPA) for policy-as-code. The scaffolder evaluates the policy before executing steps, routing to the appropriate approval workflow or proceeding automatically based on the policy decision.
    +### Making Approvals Fast + +Even when manual approval is required, it shouldn't be slow. A few practices help: + - -### Making Approvals Fast - -Even when manual approval is required, it shouldn't be slow. A few practices help: - - Approvals should be the exception, not the rule. If most requests require manual approval, you haven't built self-service - you've built a ticket system with a nicer UI. Use policy-based auto-approval aggressively for low-risk operations. @@ -387,27 +373,27 @@ A self-service platform doesn't exist in isolation. It needs to talk to your Git Most platforms need integrations across five categories: @@ -421,7 +407,10 @@ Each adapter declares its capabilities (what actions it can perform), implements This abstraction pays off when you need to swap providers. Moving from GitHub to GitLab, or from Terraform Cloud to Atlantis, becomes a configuration change rather than a rewrite of every action that touches those systems.
    @@ -506,23 +495,26 @@ Building a platform is an investment - in tooling, integrations, and ongoing mai Metrics are most useful in combination. Here's what different patterns indicate: @@ -532,7 +524,8 @@ Metrics are most useful in combination. Here's what different patterns indicate: If you can only track a few things, focus on these:
    The test of a real platform: can a new developer join, need a database, and have it provisioned without asking anyone for help? If yes, you've built a platform. If no - if they need to read Confluence, file a Jira ticket, or ping someone on Slack - you've built a prettier ticket system. + +Level 4 platform maturity isn't cheap. It requires investment in tooling, integration work, and ongoing maintenance. But the returns are proportional: developer productivity multiplied across the organization, platform team leverage instead of ticket-by-ticket toil, and the compounding benefits of standardization and automation. diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx index 2c62e6b5d..5ac3ce701 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/index.mdx @@ -13,7 +13,7 @@ Last week, a platform team I know shipped what they called a "small cleanup" to Monday morning, 40 CI pipelines failed. Three teams scrambled to update their deployment scripts. A critical security hotfix got blocked because the team couldn't deploy. The platform team spent the entire week doing emergency migrations instead of planned work. And the trust they'd built over the previous year? Gone. -This happens constantly with internal APIs. There's a temptation to treat them differently than external ones: "we can just tell people to update," "everyone's in the same building," "we'll coordinate in Slack." But internal APIs deserve __more__ versioning discipline than external ones, not less. +This happens constantly with internal APIs. There's a temptation to treat them differently than external ones: "we can just tell people to update," "everyone's in the same building," "we'll coordinate in Slack." But internal APIs deserve _more_ versioning discipline than external ones, not less. Internal customers are captive customers. They can't switch to a competitor's platform. This makes breaking their workflows worse, not better - they have no recourse except escalating to leadership or building workarounds that create tech debt. @@ -26,7 +26,8 @@ __Will existing client code still work?__ That's the question that determines wh Some changes are obviously breaking - removing endpoints, changing HTTP methods, adding required fields. These require full deprecation process, no exceptions. Other changes are obviously safe - adding optional fields, new endpoints, loosening validation. The table below draws the line.
    count include deleted items when it previously didn\'t - breaking even though the field name stays the same', }, ]} /> @@ -128,7 +129,10 @@ When you're unsure whether a change breaks existing clients, treat it as breakin Once you've identified a breaking change, follow a structured process that gives consumers time to adapt. Skipping steps is how you end up with 40 broken pipelines and a week of emergency migrations. +Automation pays for itself quickly. A codemod that handles 80% of cases automatically and flags the remaining 20% for manual review dramatically reduces the burden on consuming teams. Compatibility adapters that translate v1 requests to v2 internally buy time for slow migrators without extending your maintenance window. + ## The Invisible Success Good API versioning is invisible. Consumers barely notice migrations because they're well-communicated, well-supported, and well-timed. The new version shows up with deprecation warnings months in advance. The migration guide makes the change trivial. By the time sunset arrives, everyone's already moved on. diff --git a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx index 4d8714614..175245c44 100644 --- a/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx +++ b/src/content/articles/internal-platform-api-versioning-deprecation-breaking-changes/pdf.mdx @@ -9,14 +9,12 @@ tags: ["platform-engineering"] featured: true --- -*[SDK]: Software Development Kit -*[SLA]: Service Level Agreement -*[CI]: Continuous Integration -*[CD]: Continuous Deployment -*[REST]: Representational State Transfer +*[EOL]: End of Life *[gRPC]: Google Remote Procedure Call +*[REST]: Representational State Transfer +*[SDK]: Software Development Kit *[SemVer]: Semantic Versioning -*[EOL]: End of Life +*[SLA]: Service Level Agreement Last week, a platform team I know shipped what they called a "small cleanup" to their deployment API. They renamed a field from `service_id` to `serviceId`, removed an endpoint that was "barely used," and updated the response format to match their new schema. No version bump. No deprecation notice. Just a Friday afternoon deploy and a Slack message in `#platform-updates` that nobody read. @@ -24,12 +22,12 @@ Monday morning, 40 CI pipelines failed. Three teams scrambled to update their de This happens constantly with internal APIs. There's a temptation to treat them differently than external ones: "we can just tell people to update," "everyone's in the same building," "we'll coordinate in Slack." But internal APIs deserve _more_ versioning discipline than external ones, not less. -The same practices that make external APIs predictable - semantic versioning, deprecation policies, migration support - apply to internal platform APIs. The difference is that internal teams have higher expectations because they're colleagues, and lower patience because they have their own roadmaps that don't include "emergency migration of the deployment API." - Internal customers are captive customers. They can't switch to a competitor's platform. This makes breaking their workflows worse, not better - they have no recourse except escalating to leadership or building workarounds that create tech debt. +The same practices that make external APIs predictable - semantic versioning, deprecation policies, migration support - apply to internal platform APIs. The difference is that internal teams have higher expectations because they're colleagues, and lower patience because they have their own roadmaps that don't include "emergency migration of the deployment API." + ## Versioning Strategies ### Choosing a Versioning Scheme @@ -37,19 +35,22 @@ Internal customers are captive customers. They can't switch to a competitor's pl Four versioning approaches dominate API design, each with tradeoffs that matter for internal platforms. /api/v1/deployments, /api/v2/deployments) is explicit and visible - every request clearly shows which version it\'s using. Load balancers can route traffic easily, and deprecating old versions is straightforward. For internal platform APIs, this is usually the right choice because visibility trumps elegance. When debugging a failed pipeline at 2 AM, you want to immediately know which API version the client is calling.', + text: '/api/v1/deployments and /api/v2/deployments is explicit and visible - every request clearly shows which version it\'s using. Load balancers can route traffic easily, and deprecating old versions is straightforward. For internal platform APIs, this is usually the right choice because visibility trumps elegance. When debugging a failed pipeline at 2 AM, you want to immediately know which API version the client is calling.', }, { - lead: "Header versioning", - text: '(Accept: application/vnd.platform.v2+json) and _query parameter versioning_ (/api/services?version=2) both hide version information where developers forget to set it and debugging tools don\'t show it. Skip these unless you have specific requirements for URL stability.', + lead: "Header versioning and query parameter versioning", + text: 'Accept: application/vnd.platform.v2+json and /api/services?version=2 both hide version information where developers forget to set it and debugging tools don\'t show it. Skip these unless you have specific requirements for URL stability.', }, { lead: "Date-based versioning", - text: '(/api/2024-01-15/services) works well for APIs with frequent, incremental changes - Stripe and AWS use this successfully. The downside is version accumulation: after a few years, you have dozens of dated endpoints. This works best when you have the tooling to manage many concurrent versions.', + text: '/api/2024-01-15/services works well for APIs with frequent, incremental changes - Stripe and AWS use this successfully. The downside is version accumulation: after a few years, you have dozens of dated endpoints. This works best when you have the tooling to manage many concurrent versions.', }, ]} /> @@ -61,25 +62,29 @@ For most internal platform APIs, I recommend URL path versioning. Maximum visibi Semantic versioning gives version numbers meaning. For APIs, the interpretation is straightforward:
    +Not every change demands that level of process. Many changes are additive or relaxing — they expand what the API accepts or returns without invalidating anything consumers already depend on. The distinction matters operationally because safe changes can ship continuously without coordination, while breaking changes gate on migration timelines and consumer readiness. + +Misclassifying a safe change as a breaking change slows delivery unnecessarily; misclassifying a breaking change as safe causes outages. When the boundary is unclear — a changed default, a subtly different error shape — treat the change as breaking and let consumers confirm otherwise. It's easier to relax a deprecation notice than to roll back a broken deployment. +
    @@ -334,7 +349,11 @@ People miss messages. The developer who needs to migrate might be on vacation wh The key insight is that urgency should escalate as the deadline approaches. Early communication is broad and informational - changelog updates, Slack posts, email announcements. As sunset nears, communication becomes targeted and direct: personal outreach to remaining consumers, calendar invites for the sunset date, escalation to team leads. The table below shows the progression.
    Self-Service", + lead: "Most migrations should be completable without platform team involvement.", + text: "Provide a comprehensive migration guide with before/after code examples, a comparison table of old vs new API, an FAQ covering common questions, and a self-service validator that consumers can run to check their migration status. Support comes through async channels like #platform-help.", }, { - lead: "Tier 2: Assisted.", - text: "For consumers who need more help, offer scheduled office hours for questions, code review of migration PRs, and migration progress tracking. This tier catches teams who are stuck on edge cases or unclear about specific changes.", + title: "Tier 2:
    Assisted", + lead: "For consumers who need more help, offer scheduled office hours for questions, code review of migration PRs, and migration progress tracking.", + text: "This tier catches teams who are stuck on edge cases or unclear about specific changes.", }, { - lead: "Tier 3: Hands-on.", - text: "For high-impact consumers or complex migrations, the platform team creates the migration PRs directly. This includes pair programming sessions and production deployment support. Reserve this for critical paths where a delayed migration blocks the entire deprecation.", + title: "Tier 3:
    Hands-On", + lead: "For high-impact consumers or complex migrations, the platform team creates the migration PRs directly.", + text: "This includes pair programming sessions and production deployment support. Reserve this for critical paths where a delayed migration blocks the entire deprecation.", }, ]} />
    @@ -597,15 +630,18 @@ The easier migration is, the faster it happens. Codemods that automatically tran You can't manage what you can't measure. Deprecation metrics tell you whether migration is on track, where consumers are stuck, and whether the deprecation is causing problems. @@ -617,23 +653,26 @@ __Timeline metrics__ keep the deprecation on schedule. Compare actual milestone Think of migration as a funnel with stages: total consumers → aware → started migration → testing on v2 → production on v2 → v1 fully retired. Each stage has observable metrics. @@ -641,19 +680,26 @@ Think of migration as a funnel with stages: total consumers → aware → starte Here's what a healthy migration funnel looks like in practice. For a deployment API v2 migration with 50 consuming services: Normal - teams are reading the announcement.", }, { - text: "Week 4: 50 total → 50 aware (100%) → 30 started (60%) → 5 testing. _Good velocity._", + lead: "Week 4:", + text: "50 total → 50 aware (100%) → 30 started (60%) → 5 testing. Good velocity.", }, { - text: "Week 8: 50 total → 50 aware → 45 started (90%) → 35 testing (70%) → 20 production (40%). _On track._", + lead: "Week 8:", + text: "50 total → 50 aware → 45 started (90%) → 35 testing (70%) → 20 production (40%). On track.", }, { - text: "Week 12 (90 days out): 50 total → 48 production (96%) → 2 holdouts identified. _Target support at holdouts._", + lead: "Week 12 (90 days out):", + text: "50 total → 48 production (96%) → 2 holdouts identified. Target support at holdouts.", }, ]} /> @@ -661,7 +707,11 @@ Here's what a healthy migration funnel looks like in practice. For a deployment When consumers are stuck at a particular stage, investigate why. Often it's a missing migration tool (stuck at "started"), unclear documentation (stuck at "testing"), or a blocking bug in v2 (stuck at "production"). Rarely is it resistance - most developers would rather move on than maintain compatibility with deprecated APIs.
    +Good API versioning is invisible - consumers barely notice migrations because they're well-communicated, well-supported, and well-timed. Bad versioning is very visible: broken pipelines, emergency meetings, and angry Slack messages. Invest in the former to avoid the latter. + + The payoff is substantial. Trust with internal teams that makes future changes easier. Faster adoption of new versions because consumers know what to expect. Reduced support burden because migrations are self-service. And the platform team's time spent on planned work instead of emergency migrations. ## Quick Reference
    -Good API versioning is invisible - consumers barely notice migrations because they're well-communicated, well-supported, and well-timed. Bad versioning is very visible: broken pipelines, emergency meetings, and angry Slack messages. Invest in the former to avoid the latter. - diff --git a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx index c89ca866d..7bcfcaad1 100644 --- a/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx +++ b/src/content/articles/kubernetes-cluster-upgrade-playbook-risk-reduction/index.mdx @@ -9,45 +9,45 @@ tags: ["cloud-platforms","kubernetes","aws","azure"] featured: true --- -*[K8s]: Kubernetes -*[API]: Application Programming Interface -*[CRD]: Custom Resource Definition *[CNI]: Container Network Interface +*[CRD]: Custom Resource Definition *[CSI]: Container Storage Interface *[etcd]: Distributed key-value store used by Kubernetes +*[K8s]: Kubernetes -I watched a company avoid Kubernetes upgrades for 18 months. When they finally __had__ to upgrade for security compliance, they faced a five-version jump. Deprecated APIs were everywhere. Custom controllers broke. Workloads failed in ways nobody expected. What should have been four routine 2-hour maintenance windows became a three-week crisis involving weekend war rooms and executive escalations. +I watched a company avoid Kubernetes upgrades for 18 months. When they finally _had_ to upgrade for security compliance, they faced a five-version jump. Deprecated APIs were everywhere. Custom controllers broke. Workloads failed in ways nobody expected. What should have been four routine 2-hour maintenance windows became a three-week crisis involving weekend war rooms and executive escalations. -The lesson is counterintuitive but consistent: frequent, incremental upgrades are __less__ risky than infrequent large jumps. Kubernetes releases new versions roughly every four months, so each version you skip accumulates deprecated APIs, changed behaviors, and incompatible add-ons. A cluster three or more versions behind isn't technical debt - it's an emergency waiting to happen. The goal of this playbook is to make upgrades boring: predictable procedures that happen quarterly without drama. +The lesson is counterintuitive but consistent: frequent, incremental upgrades are _less_ risky than infrequent large jumps. Kubernetes releases new versions roughly every four months, so each version you skip accumulates deprecated APIs, changed behaviors, and incompatible add-ons. A cluster three or more versions behind isn't technical debt - it's an emergency waiting to happen. The goal of this playbook is to make upgrades boring: predictable procedures that happen quarterly without drama. ## The Pre-Upgrade Checklist Most upgrade failures trace back to skipped preparation. Before touching the cluster, work through four categories of readiness checks.
    @@ -229,7 +236,8 @@ If you're on managed Kubernetes, test what "rollback" actually means for your pr __Worker node rollback__ is much simpler. If a node is misbehaving after upgrade, you can downgrade its kubelet without affecting the rest of the cluster: cordon, drain, downgrade, uncordon. Some teams use a blue-green node pool strategy - creating new nodes at the target version, then draining old nodes. With this approach, rollback is trivial: uncordon the old pool and delete the new one.
    The riskiest upgrade is the one you've been avoiding. Each version you skip accumulates: deprecated APIs, changed behaviors, incompatible add-ons. A cluster 3+ versions behind is an emergency waiting to happen. +The lesson: frequent, incremental upgrades are _less_ risky than infrequent large jumps. The goal of this playbook is to make upgrades boring - predictable procedures that happen quarterly without drama. + ## Upgrade Preparation ### Pre-Upgrade Checklist @@ -40,19 +39,24 @@ The riskiest upgrade is the one you've been avoiding. Each version you skip accu Most upgrade failures trace back to skipped preparation. Before touching the cluster, work through four categories of readiness checks. kubectl get all -A -o yaml. Most importantly, document and test your rollback procedure in a non-production environment before you need it.', }, ]} /> @@ -75,7 +79,8 @@ Code: etcd backup before upgrade. __Compatibility verification__ catches the issues that break workloads. Check that your add-ons - CNI plugin, CSI drivers, ingress controller, cert-manager, monitoring stack - are compatible with the target version. Run custom controllers against the target version in staging. Validate workload manifests for deprecated APIs.
    div]:!pt-3", + }} items={[ { - text: "Create new node pool at target version (with a `NoSchedule` taint to prevent scheduling)", + lead: 'Create new node pool at target version (with a NoSchedule taint to prevent scheduling)', }, { - text: "Wait for all new nodes to show Ready", + lead: "Wait for all new nodes to show Ready", }, { - text: "Remove the taint from the new pool", + lead: "Remove the taint from the new pool", }, { - text: "Cordon the old pool (no new pods scheduled)", + lead: "Cordon the old pool (no new pods scheduled)", }, { - text: "Drain the old pool (pods migrate to new pool)", + lead: "Drain the old pool (pods migrate to new pool)", }, { - text: "Validate workloads are healthy on the new pool", + lead: "Validate workloads are healthy on the new pool", }, { - text: "If healthy, delete the old pool. If not, uncordon the old pool and delete the new one.", + lead: "If healthy, delete the old pool. If not, uncordon the old pool and delete the new one.", }, ]} /> @@ -553,19 +577,22 @@ Be aware that restoring from an etcd backup resets cluster state to the backup t For managed Kubernetes, rollback options are more limited: gcloud container clusters upgrade --cluster-version=PREVIOUS_VERSION.', }, { lead: "AKS", - text: "doesn't support control plane downgrades. You'll need to create a new cluster or open an Azure support ticket.", + text: "Doesn't support control plane downgrades. You'll need to create a new cluster or open an Azure support ticket.", }, ]} /> @@ -614,7 +641,11 @@ Code: Ansible playbook for worker node rollback. If you used blue-green node pools for the upgrade, rollback is trivial: uncordon the old pool and delete the new one. This is one of the strongest arguments for blue-green upgrades in production.
    @@ -733,7 +766,8 @@ Code: Basic infrastructure validation script. Validation should be automated and fast. If checking takes an hour, you won't check after each phase - you'll rush through the upgrade and validate once at the end, when problems are hardest to diagnose. A comprehensive validation suite that runs in 5 minutes lets you validate after each phase, catching issues early when they're easiest to fix.
    The goal is making upgrades so routine they're boring. Quarterly upgrades, practiced procedures, automated validation, and quick rollback capability transform upgrades from scary events into regular maintenance. The teams that upgrade often are the teams that upgrade well. + +The pattern is clear: small, regular upgrades beat large, infrequent ones. A quarterly 1.28→1.29 upgrade with well-tested procedures is dramatically safer than an annual 1.25→1.29 jump with four versions of changelog to audit and four times the deprecations to handle. diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx index e8ced6a0e..8ca477f2e 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/index.mdx @@ -10,19 +10,19 @@ featured: true --- *[K8s]: Kubernetes -*[VPA]: Vertical Pod Autoscaler -*[QoS]: Quality of Service *[OOM]: Out of Memory +*[QoS]: Quality of Service +*[VPA]: Vertical Pod Autoscaler I worked with a team running 50 nodes who'd never looked at their actual resource utilization. When we finally checked, average CPU usage was 15% and memory was 25%. Resource requests were 3-4x what workloads actually used. Right-sizing those requests and enabling the cluster autoscaler dropped them to 20 nodes - a 60% cost reduction with zero performance impact. This isn't unusual. Compute is typically 60-80% of Kubernetes spend, yet most teams have no visibility into whether their resource requests match actual usage. The default behavior - developers requesting "enough" resources with generous padding - leads to clusters running at 20-30% utilization while paying for 100%. -The good news: Kubernetes cost optimization has three levers, and two of them deliver 80% of the savings. Use cheaper compute (spot instances), use less compute (right-sizing), and use compute more efficiently (bin packing). This article focuses on right-sizing and spot - the changes you can make this week that will actually move your bill. +The good news: Kubernetes cost optimization has three levers, and two of them deliver 80% of the savings. Use cheaper compute (spot instances), use less compute (right-sizing), and use compute more efficiently (bin packing). This article focuses on right-sizing and spot - the changes you can make this week that will actually move your bill. ## The Resource Model That Costs You Money -The Kubernetes resource model trips up a lot of teams because __requests__ and __limits__ sound similar but do completely different things. +The Kubernetes resource model trips up a lot of teams because _requests_ and _limits_ sound similar but do completely different things. __Requests__ are what the scheduler uses for placement decisions. When you set `cpu: 500m` as a request, you're telling Kubernetes "this container needs half a CPU core guaranteed." The scheduler won't place your pod on a node unless that capacity is available. Requests are promises - the node reserves that capacity for your container whether you use it or not. @@ -31,7 +31,8 @@ __Limits__ are enforcement boundaries. CPU limits throttle - if your container t This distinction matters for cost because __requests determine how many nodes you need__. If every pod requests 1 CPU but only uses 0.1, you're paying for 10x the capacity you need. The scheduler sees the cluster as full when it's actually 90% idle. Here's how they compare:
    div]:!pt-3 mb-6", + wrapper: "sm:ml-6 sm:mr-12 mb-6", + }} items={[ { - text: "Deploy VPAs in recommendation mode for all deployments", + lead: "Deploy VPAs in recommendation mode for all deployments", }, { - text: "Review recommendations weekly", + lead: "Review recommendations weekly", }, { - text: "Apply changes through the normal deployment process (not via VPA auto-apply)", + lead: "Apply changes through the normal deployment process (not via VPA auto-apply)", }, { - text: "Validate that workloads remain healthy after the change", + lead: "Validate that workloads remain healthy after the change", }, ]} /> diff --git a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx index 8971c3a4d..d5d043fb8 100644 --- a/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx +++ b/src/content/articles/kubernetes-cost-optimization-resource-sizing-spot-instances/pdf.mdx @@ -9,27 +9,27 @@ tags: ["cloud-platforms","kubernetes","prometheus","aws","azure"] featured: true --- -*[K8s]: Kubernetes -*[VPA]: Vertical Pod Autoscaler -*[HPA]: Horizontal Pod Autoscaler -*[QoS]: Quality of Service -*[OOM]: Out of Memory -*[EC2]: Elastic Compute Cloud -*[GCE]: Google Compute Engine *[AKS]: Azure Kubernetes Service +*[EC2]: Elastic Compute Cloud *[EKS]: Elastic Kubernetes Service +*[GCE]: Google Compute Engine *[GKE]: Google Kubernetes Engine +*[HPA]: Horizontal Pod Autoscaler +*[K8s]: Kubernetes +*[OOM]: Out of Memory +*[QoS]: Quality of Service +*[VPA]: Vertical Pod Autoscaler Compute is typically 60-80% of Kubernetes spend, yet most teams have no visibility into whether their resource requests match actual usage. The default behavior - developers requesting "enough" resources with generous padding - leads to clusters running at 20-30% utilization while paying for 100%. I worked with a team running 50 nodes who'd never looked at their actual resource utilization. When we finally checked, average CPU usage was 15% and memory was 25%. Resource requests were 3-4x what workloads actually used. Right-sizing those requests and enabling the cluster autoscaler dropped them to 20 nodes - a 60% cost reduction with zero performance impact. The workloads didn't notice because they were never using the resources they'd requested. -Cost optimization isn't about penny-pinching. It's about eliminating waste that funds nothing. The savings can fund actual improvements: better observability, more environments, faster CI runners, or simply budget for the next project. - Kubernetes cost optimization has three levers: use cheaper compute (spot instances), use less compute (right-sizing), and use compute more efficiently (bin packing). Most savings come from the boring work of accurate resource requests, not clever architecture. +Cost optimization isn't about penny-pinching. It's about eliminating waste that funds nothing. The savings can fund actual improvements: better observability, more environments, faster CI runners, or simply budget for the next project. + ## Understanding Kubernetes Resource Model ### Requests vs Limits @@ -45,7 +45,8 @@ This distinction matters for cost because __requests determine how many nodes yo Kubernetes assigns a QoS class based on how you set resources. __Guaranteed__ pods (requests equal limits for all resources) get the highest priority and are evicted last under pressure. __Burstable__ pods (requests less than limits) are the common case. __BestEffort__ pods (no requests or limits) are evicted first - avoid these in production. To control your pod's QoS class, set both CPU and memory requests equal to their limits for Guaranteed, or set requests lower than limits (or omit limits) for Burstable.
    Start VPA in "Off" mode to get recommendations without automatic changes. Review recommendations weekly, apply them through your normal deployment process, and validate that workloads remain healthy. Automatic modes work but remove the human verification step. +VPA has other update modes - "Initial" applies recommendations only when pods are created, and "Recreate" evicts pods to apply new resource values. These work, but they remove the verification step. For production workloads, I prefer explicit control over when resource changes roll out. + ## Spot and Preemptible Instances ### Spot Instance Fundamentals @@ -293,7 +294,8 @@ When a spot instance is about to be reclaimed, something needs to cordon the nod On AWS, deploy the `aws-node-termination-handler` as a DaemonSet on spot nodes. It monitors the instance metadata service for interruption warnings and triggers a drain when one arrives. GKE has built-in handling. AKS requires a separate node drain handler.
    Cost optimization is continuous, not a one-time project. Usage patterns change, new services deploy with default resources, and spot savings vary. Build cost review into your regular operations - a monthly meeting that reviews efficiency dashboards and identifies right-sizing opportunities keeps savings compounding. + +Start with visibility. Deploy VPA in recommendation mode across your workloads and let it collect data for a week. Query efficiency ratios to find your worst offenders - the deployments requesting 4x what they use - and fix those first. The Pareto principle applies: 20% of your workloads probably account for 80% of your waste. Once you've addressed the obvious outliers, build a dashboard that tracks efficiency by namespace so teams can self-service. + +Then look at spot instances. Any workload that can tolerate losing a node - web servers, API servers, workers, CI runners - is a spot candidate. With proper interruption handling (multiple replicas, zone spreading, graceful shutdown), spot instances deliver 50-70% savings on compute with minimal operational overhead. diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx index 059925e82..d6c26bbc3 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/index.mdx @@ -21,7 +21,7 @@ A startup I advised last year had a familiar story. They'd spent three months mi The kicker? Their traffic was 50 requests per minute. A $20/month VM could have handled their load with room to spare. -This isn't an indictment of Kubernetes. It's a recognition that K8s solves specific problems at specific scales, and choosing it prematurely creates problems you wouldn't otherwise have. The goal isn't to use the most powerful tool available. It's to use the __right__ tool for where you are today. +This isn't an indictment of Kubernetes. It's a recognition that K8s solves specific problems at specific scales, and choosing it prematurely creates problems you wouldn't otherwise have. The goal isn't to use the most powerful tool available. It's to use the _right_ tool for where you are today. ## The Kubernetes Complexity Tax @@ -40,7 +40,10 @@ This isn't optional learning. It's the baseline for keeping production running. The managed Kubernetes pricing page shows the control plane cost. It doesn't show the full picture.
    Before your next infrastructure decision, run through the scorecard with your team. Be honest about your current state, not your aspirations. The right tool for today might not be the right tool for next year, and that's fine. You can always migrate later when the complexity becomes worth it. + +This doesn't mean Kubernetes is wrong. It means Kubernetes is __contextual__. The organizations that get the most value from K8s are the ones who adopted it because they needed its specific capabilities, not because it seemed like the professional choice. diff --git a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx index 61a51e3fd..e9dbfc1c5 100644 --- a/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx +++ b/src/content/articles/kubernetes-decision-framework-when-not-to-use/pdf.mdx @@ -28,12 +28,12 @@ Here's a scenario I've seen play out multiple times. A five-person startup runs Six months later, after weeks of migration work, they have the same application running on Kubernetes. But now they're also maintaining a cluster, debugging CNI networking issues, managing Helm charts, configuring ingress controllers, and spending 40% of engineering time on infrastructure instead of product. They just bought complexity without gaining capability. -Kubernetes solves specific problems: multi-service orchestration, complex networking, multi-team deployment isolation, sophisticated scaling requirements. If you don't have those problems, you're paying the Kubernetes tax without getting the benefits. - The goal of infrastructure is to run your workloads reliably and efficiently, not to use any particular technology. If your workloads run better on VMs, serverless, or managed services, choosing those over Kubernetes isn't settling for less. It's making the right engineering decision. +Kubernetes solves specific problems: multi-service orchestration, complex networking, multi-team deployment isolation, sophisticated scaling requirements. If you don't have those problems, you're paying the Kubernetes tax without getting the benefits. + ## The Kubernetes Complexity Tax ### What You're Signing Up For @@ -57,8 +57,10 @@ For a team of five running ten services on Kubernetes, expect 20-30 hours per mo Compare that to the same workloads on VMs. Operations drops to 5-10 hours monthly. Debugging is simpler because there's less abstraction. OS updates are less complex than Kubernetes upgrades. The learning burden is dramatically lower. You might spend 15-25 hours per month total.
    @@ -127,6 +128,7 @@ The first question isn't "Should we use Kubernetes?" It's "What problems are we @@ -138,7 +140,10 @@ The service count matters because Kubernetes overhead amortizes across workloads Kubernetes fits well when your workloads have these characteristics: @@ -212,13 +222,15 @@ __Day one prerequisites__ (must have before starting): __Day thirty__ (need within first month): @@ -226,10 +238,15 @@ __Day thirty__ (need within first month): __Day ninety__ (for sustainable operations): @@ -249,7 +266,7 @@ If Kubernetes isn't the right choice, what is? The answer depends on your worklo VMs aren't outdated technology. For many workloads, they're the right choice. @@ -323,18 +342,21 @@ Two factors dominate the infrastructure decision: traffic patterns and team size PaaS platforms trade control for speed. When time-to-market matters more than infrastructure flexibility, that's a good trade. Many successful companies run on PaaS well past their startup phase. If your PaaS bill is $5,000/month and your engineering team's time is worth $50,000/month, spending 20% of engineering time on Kubernetes operations to save $3,000 is a bad trade. +When should you consider leaving PaaS? When your monthly bill exceeds $5k-10k, when you need more infrastructure control, when performance requirements exceed PaaS limits, or when compliance requires specific configurations. When __shouldn't__ you leave? Because "it's not real infrastructure," because everyone else uses Kubernetes, because you might need to scale someday, or because you want to learn Kubernetes. Those are bad reasons. + ## Migration Considerations ### If You're Already on Kubernetes @@ -359,7 +381,7 @@ Many successful companies run on PaaS well past their startup phase. If your Paa Maybe you adopted Kubernetes and it's not working out. That's not a failure — it's information. Here are the signals that suggest reconsidering: If you're evaluating Kubernetes, start with the scorecard. If you score below 7, save yourself months of complexity and pick a simpler path. Your future self will thank you. + +The best infrastructure choice considers workload characteristics, team capabilities, and total cost of ownership. Not industry trends. Not what "everyone else" is using. The question isn't "Should we use Kubernetes?" It's "What infrastructure best serves our workloads, team, and business?" + +Sometimes that's Kubernetes. Often, especially for smaller organizations, it's something simpler. VMs with modern tooling. Serverless for variable workloads. PaaS for fast deployment. Managed services for stateful workloads. The best engineers choose the right tool, not the most powerful one. diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx index 9cb34aa2c..dbf8b4a8a 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/index.mdx @@ -18,7 +18,7 @@ An application works perfectly in development. You deploy it to Kubernetes, and That single configuration option — the default in every Kubernetes cluster — causes every lookup like `api.stripe.com` to first try `api.stripe.com.default.svc.cluster.local`, then `api.stripe.com.svc.cluster.local`, then `api.stripe.com.cluster.local`, before finally trying the actual hostname. Three failed lookups, each potentially timing out, equals mysterious latency. -I've watched teams lose days to this problem. The symptoms look like network issues, timeout misconfigurations, or flaky external services. But most Kubernetes DNS latency traces back to one misunderstood setting. The good news: once you know what to look for, you can diagnose the problem in 30 seconds and fix it in another 30. +I've watched teams lose days to this problem. The symptoms look like network issues, timeout misconfigurations, or flaky external services. But most Kubernetes DNS latency traces back to one misunderstood setting. The good news: once you know what to look for, you can diagnose the problem in 30 seconds and fix it in another 30. ## Understanding the ndots Problem @@ -28,6 +28,7 @@ Here's what happens when your application looks up `api.stripe.com`. The resolve @@ -125,7 +126,11 @@ For most teams, I recommend starting with trailing dots for immediate relief, th To make this concrete, here's what the fix looks like in practice. Before applying either fix, a simple HTTP request to an external API:
    kubectl run dns-debug --image=nicolaka/netshoot --rm -it --restart=Never -- bash', }, { lead: "Run the ndots test", - text: "Compare `time nslookup api.example.com` vs `time nslookup api.example.com.`", + text: 'Compare time nslookup api.example.com vs time nslookup api.example.com.', }, { lead: "Interpret the results", diff --git a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx index b77e4d97a..00bd2d50c 100644 --- a/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx +++ b/src/content/articles/kubernetes-dns-debugging-ndots-coredns-troubleshooting/pdf.mdx @@ -30,12 +30,12 @@ That single configuration option, the default in every Kubernetes cluster, cause I've seen this pattern repeatedly. Mysterious application failures, latency spikes, and "works sometimes" bugs that trace back to DNS. The Kubernetes DNS system adds layers of complexity that catch even experienced engineers off guard. When your application can't reach a service, DNS should be your first suspect. -This article covers the DNS internals you need to debug production issues: how `resolv.conf` and search domains work, why `ndots` creates overhead for external lookups, how to tune CoreDNS, and a systematic workflow for diagnosing DNS problems. - When something is slow or broken and you don't know why, check DNS first. Not because DNS is always the problem, but because it's the problem often enough that ruling it out early saves hours of debugging in the wrong direction. +This article covers the DNS internals you need to debug production issues: how `resolv.conf` and search domains work, why `ndots` creates overhead for external lookups, how to tune CoreDNS, and a systematic workflow for diagnosing DNS problems. + ## Kubernetes DNS Architecture Understanding the DNS stack is essential for effective debugging. Kubernetes doesn't use a single DNS server. It's a layered system where your pod's `resolv.conf` configuration determines how queries are constructed, CoreDNS handles the actual resolution, and upstream servers resolve anything outside the cluster. Each layer has its own configuration and failure modes. @@ -92,7 +92,7 @@ Code: Examining pod DNS configuration. The options in `resolv.conf` control DNS behavior in ways that matter for debugging and performance tuning.
    @@ -450,6 +451,7 @@ Most DNS problems fall into a few categories. The decision tree below helps you @@ -457,7 +459,12 @@ Most DNS problems fall into a few categories. The decision tree below helps you Here's what to check for each common scenario: ClusterFirst', td: ['Yes', 'Default for standard workloads'], }, { - th: '`Default`', + th: 'Default', td: ["No", "Pod needs the node's DNS, not cluster DNS"], }, { - th: '`ClusterFirstWithHostNet`', + th: 'ClusterFirstWithHostNet', td: ['Yes', 'Pods with `hostNetwork: true` that still need cluster DNS'], }, { - th: '`None`', - td: ['No', 'Fully custom DNS configuration via `dnsConfig`'], + th: 'None', + td: ['No', 'Fully custom DNS configuration via dnsConfig'], }, ], }, @@ -665,27 +672,31 @@ For production clusters, the optimizations that matter most are negative caching When connectivity issues arise, run through this checklist: kubectl get pods -n kube-system -l k8s-app=kube-dns', }, { lead: "Can you resolve internal names?", - text: "`nslookup kubernetes.default` from inside a pod", + text: 'nslookup kubernetes.default from inside a pod', }, { lead: "Can you resolve external names?", - text: "`nslookup google.com` from inside a pod", + text: 'nslookup google.com from inside a pod', }, { lead: "Is it an ndots issue?", - text: "Compare `time nslookup api.example.com` vs `time nslookup api.example.com.`", + text: 'Compare time nslookup api.example.com vs `time nslookup api.example.com.`', }, { lead: "Check CoreDNS logs", - text: "for errors: `kubectl logs -n kube-system -l k8s-app=kube-dns --tail=50`", + text: 'for errors: kubectl logs -n kube-system -l k8s-app=kube-dns --tail=50', }, ]} /> diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx index 9b4bfd085..d609fadca 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/index.mdx @@ -9,18 +9,19 @@ tags: ["cloud-platforms","kubernetes","prometheus"] featured: true --- -An e-commerce team configures HPA with a 50% CPU target. During a flash sale, traffic spikes 10x in 30 seconds. HPA takes 15 seconds to detect the load, another 15 seconds for its evaluation cycle, then the stabilization window kicks in. Meanwhile, new pods need scheduling, image pulls, and readiness probes. By the time capacity catches up—3+ minutes later — frustrated users have already left. +An e-commerce team configures HPA with a 50% CPU target. During a flash sale, traffic spikes 10x in 30 seconds. HPA takes 15 seconds to detect the load, another 15 seconds for its evaluation cycle, then the stabilization window kicks in. Meanwhile, new pods need scheduling, image pulls, and readiness probes. By the time capacity catches up — 3+ minutes later — frustrated users have already left. I've watched this play out repeatedly. The Horizontal Pod Autoscaler looks deceptively simple: set a target CPU percentage, and Kubernetes scales your pods automatically. In practice, teams discover that HPA reacts too slowly to traffic spikes, oscillates between scaling up and down, or scales on entirely the wrong signals. -The problem is that HPA is __reactive__, not predictive. By the time it decides to scale, your workload is already under stress. Tuning HPA means understanding where delays come from, choosing metrics that respond quickly, and configuring behavior that matches your traffic patterns. +The problem is that HPA is _reactive_, not predictive. By the time it decides to scale, your workload is already under stress. Tuning HPA means understanding where delays come from, choosing metrics that respond quickly, and configuring behavior that matches your traffic patterns. ## The Delay Problem The HPA controller runs every 15 seconds by default, querying the metrics server and calculating how many replicas are needed. That sounds fast, but the total time from "traffic spike begins" to "new capacity receives traffic" is much longer. Every step in the pipeline adds latency.
    -If your traffic is predictable — business hours, scheduled events, marketing campaigns — don't rely on HPA to catch up. Pre-scale ahead of time using KEDA cron triggers or simple CronJobs. HPA's job is handling __unexpected__ variance around your baseline, not scrambling to meet traffic you knew was coming. +If your traffic is predictable — business hours, scheduled events, marketing campaigns — don't rely on HPA to catch up. Pre-scale ahead of time using KEDA cron triggers or simple CronJobs. HPA's job is handling _unexpected_ variance around your baseline, not scrambling to meet traffic you knew was coming. Start with defaults, observe behavior under real load, and tune based on what you see. If you're scaling too slowly, reduce stabilization windows and increase policy percentages. If you're oscillating, increase stabilization and lower your target utilization. There's no universal "best" configuration — only the configuration that matches your traffic pattern. diff --git a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx index 3faec4689..64631b8d9 100644 --- a/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx +++ b/src/content/articles/kubernetes-hpa-autoscaling-metrics-tuning-latency/pdf.mdx @@ -25,12 +25,12 @@ The Horizontal Pod Autoscaler (HPA) looks deceptively simple: set a target CPU p I've watched this play out repeatedly. An e-commerce team configures HPA with a 50% CPU target. During a flash sale, traffic spikes 10x in 30 seconds. HPA takes 15 seconds to detect the load via the metrics server, another 15 seconds for the controller sync, then the stabilization window kicks in. Meanwhile, new pods need to be scheduled, images pulled, and readiness probes passed. By the time capacity catches up—3+ minutes later — frustrated users have already left. The service survived, but barely. -The lesson: HPA is reactive, not predictive. By the time it decides to scale, your workload is already under stress. The goal of HPA tuning is to minimize that reaction time while avoiding oscillation — a balance that requires understanding your traffic patterns, choosing the right metrics, and configuring stabilization windows that match your workload's characteristics. - HPA responds to current conditions, not anticipated load. If your traffic can spike faster than HPA can respond (typically 30-90 seconds minimum), you need either pre-scaling for known events, higher baseline capacity, or request queuing to absorb the delay. +The lesson: HPA is reactive, not predictive. By the time it decides to scale, your workload is already under stress. The goal of HPA tuning is to minimize that reaction time while avoiding oscillation — a balance that requires understanding your traffic patterns, choosing the right metrics, and configuring stabilization windows that match your workload's characteristics. + ## HPA Fundamentals ### How HPA Works @@ -45,6 +45,7 @@ If you have 3 replicas running at 75% CPU with a target of 50%, HPA calculates $ @@ -84,7 +85,8 @@ The 50% target might seem conservative, but it's intentional. You want headroom The HPA loop sounds fast—15-second intervals — but the total time from "traffic spike begins" to "new capacity receives traffic" is much longer. Every step in the pipeline adds latency.
    @@ -234,19 +241,32 @@ Custom metrics let you scale on application-specific signals instead of generic HPA supports three metric types, each suited to different scenarios: @@ -398,18 +418,21 @@ Code: Asymmetric HPA behavior — aggressive scale-up, conservative scale-down. The `selectPolicy` field determines how HPA chooses between multiple policies: @@ -426,7 +450,7 @@ The `selectPolicy` field determines how HPA chooses between multiple policies: Different traffic patterns call for different behavior configurations. The tradeoff is always between responsiveness (scale fast to handle spikes) and stability (avoid oscillation and wasted resources).
    @@ -751,6 +782,7 @@ When HPA isn't behaving as expected, work through this diagnostic flow: @@ -842,23 +874,9 @@ Code: Multi-metric HPA using CPU, RPS, and latency. Here's how the calculation works. Suppose current state is 5 replicas: - +- __CPU metric__: 60% utilization, target 50% → $$5 \times (60/50) = 6$$ replicas +- __RPS metric__: 120 RPS/pod, target 100 → $$5 \times (120/100) = 6$$ replicas +- __Latency metric__: 400ms P99, target 500ms → $$5 \times (400/500) = 4$$ replicas HPA takes the maximum: 6 replicas. The latency metric calculates fewer replicas than currently running (4 < 5), which would suggest scaling down if it were the only metric — but CPU and RPS both want more capacity, so we scale up. This "max wins" behavior ensures you don't under-provision when __any__ metric indicates stress. @@ -935,7 +953,7 @@ Use KEDA when you need scale-to-zero (queue workers, batch jobs, dev environment HPA tuning comes down to four principles: -The tuning process is iterative. Start with defaults, run under real load, and watch what happens. If you're scaling too slowly, reduce stabilization windows and increase policy percentages. If you're oscillating, increase stabilization and lower your target utilization. If you're wasting money on idle pods, tighten scale-down policies or raise target utilization. There's no universal "best" configuration — only the configuration that matches your traffic pattern. - The best HPA configuration is one you never think about. Traffic varies, capacity adjusts, users don't notice. Getting there requires measuring actual behavior — not guessing at configurations. Instrument your HPA, watch its scaling decisions under real load, and tune based on data. The article's examples are starting points, not destinations. + +The tuning process is iterative. Start with defaults, run under real load, and watch what happens. If you're scaling too slowly, reduce stabilization windows and increase policy percentages. If you're oscillating, increase stabilization and lower your target utilization. If you're wasting money on idle pods, tighten scale-down policies or raise target utilization. There's no universal "best" configuration — only the configuration that matches your traffic pattern. diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx index f492c8490..2e5deb5a1 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/index.mdx @@ -9,17 +9,17 @@ tags: ["cloud-platforms","kubernetes"] featured: true --- +*[CRD]: Custom Resource Definition *[GA]: General Availability -*[TLS]: Transport Layer Security -*[TCP]: Transmission Control Protocol -*[UDP]: User Datagram Protocol *[gRPC]: Google Remote Procedure Call -*[CRD]: Custom Resource Definition *[RBAC]: Role-Based Access Control +*[TCP]: Transmission Control Protocol +*[TLS]: Transport Layer Security +*[UDP]: User Datagram Protocol You've spent months perfecting your NGINX Ingress configuration. The annotations are just right, traffic flows smoothly, and you've documented every quirk. Then someone decides you're switching to Traefik. Or Contour. Or migrating to a service mesh that uses Gateway API. Suddenly, none of your annotations work. Some are silently ignored, others cause validation errors, and you're left wondering why Kubernetes made this so hard. -The answer: Ingress was designed in 2015 as a minimal abstraction. It defines __what__ you want (route traffic to services) but leaves __how__ entirely to the controller. Every controller filled that gap differently, creating the annotation mess you're now untangling. +The answer: Ingress was designed in 2015 as a minimal abstraction. It defines _what_ you want (route traffic to services) but leaves _how_ entirely to the controller. Every controller filled that gap differently, creating the annotation mess you're now untangling. Gateway API, which reached GA in 2023, takes a different approach. It's more expressive, more portable, and designed for multi-team environments. But more powerful doesn't always mean better for your use case. I've seen teams migrate to Gateway API because "it's the future," only to discover that their simple routing requirements worked perfectly with Ingress — they just added complexity without new capabilities. @@ -90,7 +90,11 @@ Code: HTTPRoute with cross-namespace reference to a shared Gateway. The critical difference: HTTPRoute uses native fields for routing configuration, not annotations. These fields work identically across any Gateway API implementation — Envoy Gateway, Istio, Contour, Kong, NGINX Gateway Fabric. Portability is built in, not bolted on.
    td:nth-of-type(2)]:!font-normal", + }} content={{ thead: { th: ['Aspect', 'Ingress', 'Gateway API'], @@ -133,12 +137,12 @@ Gateway API's layered architecture also enables something Ingress can't: applica The decision depends on your current situation, not abstract feature comparisons. - - ### When to Stick with Ingress Your routing needs are simple — host-based routing, path prefixes, TLS termination. If everything lives in one namespace (or a small number managed by one team) and your current setup works without friction, Ingress is fine. There's no deprecation timeline. The Kubernetes networking SIG has confirmed both APIs will coexist indefinitely. + + Migrating because Gateway API is "the future" wastes engineering time. I've watched teams spend weeks converting resources, only to end up with equivalent functionality in a more complex setup. They'd have been better served improving their deployment pipeline or writing better runbooks. Ingress makes sense for: a single application with a few endpoints, internal tools with basic routing, microservices where one team owns everything. diff --git a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx index 009cc8c8a..8df4c1e95 100644 --- a/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx +++ b/src/content/articles/kubernetes-ingress-gateway-api-comparison-migration/pdf.mdx @@ -12,14 +12,14 @@ featured: true import ingressDiagram from "./diagrams/ingress-uses-flat-resources-gateway-api-uses-shared-gateway.jpg" import roleBasedDiagram from "./diagrams/role-based-ownership-with-gateway-api.jpg" +*[CRD]: Custom Resource Definition *[GA]: General Availability -*[TLS]: Transport Layer Security -*[TCP]: Transmission Control Protocol -*[UDP]: User Datagram Protocol *[gRPC]: Google Remote Procedure Call -*[SNI]: Server Name Indication -*[CRD]: Custom Resource Definition *[RBAC]: Role-Based Access Control +*[SNI]: Server Name Indication +*[TCP]: Transmission Control Protocol +*[TLS]: Transport Layer Security +*[UDP]: User Datagram Protocol Kubernetes Ingress has been the standard for HTTP routing since 2015, but its limitations led to every ingress controller implementing different annotations and behaviors. If you've ever tried to port an Ingress configuration from NGINX to Traefik, you've felt this pain — annotations that worked perfectly on one controller are silently ignored by another. Gateway API, which launched in 2020 and reached GA in 2023, addresses these limitations with a more expressive, portable, and role-oriented design. @@ -27,19 +27,19 @@ But more powerful doesn't always mean better for your use case. I've seen this play out both ways. A team running NGINX Ingress Controller decided to migrate to Gateway API because "it's the future." They spent two weeks converting resources, only to discover that their simple host-based routing with TLS termination worked perfectly with Ingress — Gateway API added complexity without new capabilities. Meanwhile, another team I worked with was managing a multi-tenant platform with complex traffic splitting and cross-namespace routing. They'd accumulated a mess of hacky Ingress annotations to work around limitations. For them, Gateway API solved real problems and simplified their configuration. -The lesson: evaluate based on your actual requirements, not industry hype. The choice between Ingress and Gateway API depends on your current tooling, team structure, and routing complexity — not on which technology is newer. - There's no deprecation timeline for Ingress. The Kubernetes networking SIG has stated both APIs will be supported indefinitely. Plan your technology choices assuming Ingress remains viable for at least the next 5-10 years. +The lesson: evaluate based on your actual requirements, not industry hype. The choice between Ingress and Gateway API depends on your current tooling, team structure, and routing complexity — not on which technology is newer. + ## Understanding the Models Before diving into features and migration, it helps to understand how these two APIs think about routing. Ingress is flat — one resource type handles everything. Gateway API is layered — different resource types for different concerns, managed by different teams. ### Ingress Model -Ingress uses a single resource type that combines routing rules, TLS configuration, and backend references. Everything lives in one YAML file, which makes simple cases easy but complex cases awkward. The catch is that Ingress itself only defines the __interface__ — the actual behavior depends entirely on which controller you're running. NGINX Ingress Controller interprets annotations one way; Traefik interprets them differently; some controllers ignore certain annotations entirely. +Ingress uses a single resource type that combines routing rules, TLS configuration, and backend references. Everything lives in one YAML file, which makes simple cases easy but complex cases awkward. The catch is that Ingress itself only defines the _interface_ — the actual behavior depends entirely on which controller you're running. NGINX Ingress Controller interprets annotations one way; Traefik interprets them differently; some controllers ignore certain annotations entirely. ```yaml title="ingress-example.yaml" # Single Ingress resource handles routing, TLS, and backend config @@ -161,6 +161,7 @@ Notice how the HTTPRoute in the `production` namespace can reference a Gateway i @@ -168,7 +169,11 @@ Notice how the HTTPRoute in the `production` namespace can reference a Gateway i The table below summarizes the key differences. If you're scanning for a quick answer: Ingress is simpler when you don't need the advanced features, Gateway API is more capable when you do.
    td:nth-of-type(2)]:!font-normal", + }} content={{ thead: { th: ['Aspect', 'Ingress', 'Gateway API'], @@ -220,7 +225,10 @@ Gateway API's layered model adds complexity. If you don't need cross-namespace r Beyond basic host and path matching, modern routing often requires matching on headers, query parameters, or HTTP methods. Ingress provides limited support for these patterns — you're dependent on controller-specific annotations that may or may not exist. Gateway API makes advanced matching a first-class feature.
    td:nth-of-type(2)]:!font-normal", + }} content={{ thead: { th: ['Capability', 'Ingress', 'Gateway API'], @@ -380,7 +388,11 @@ Code: gRPC routing with method-level matching in Gateway API. The table below summarizes protocol support across both APIs. For HTTP workloads, either option works. For anything else, Gateway API is your only portable choice.
    ✓ Native', ' HTTPRoute', 'GA'], }, { th: 'HTTPS (terminate)', - td: ['✓ Native', '✓ HTTPRoute + TLS', 'GA'], + td: [' Native', ' HTTPRoute + TLS', 'GA'], }, { th: 'HTTPS (passthrough)', - td: ['Annotation-dependent', '✓ TLSRoute', 'GA'], + td: ['Annotation-dependent', ' TLSRoute', 'GA'], }, { th: 'TCP', - td: ['Not supported', '✓ TCPRoute', 'Beta'], + td: ['Not supported', ' TCPRoute', 'Beta'], }, { th: 'UDP', - td: ['Not supported', '✓ UDPRoute', 'Alpha'], + td: ['Not supported', ' UDPRoute', 'Alpha'], }, { th: 'gRPC', - td: ['Annotation-dependent', '✓ GRPCRoute', 'GA'], + td: ['Annotation-dependent', ' GRPCRoute', 'GA'], }, ], }, @@ -420,7 +432,7 @@ The table below summarizes protocol support across both APIs. For HTTP workloads ## Role-Based Ownership -In organizations with multiple teams, the question of __who manages what__ becomes critical. With Ingress, there's no natural separation — anyone with permission to create Ingress resources in a namespace can affect cluster-wide routing behavior. This leads to either overly permissive access (risky) or centralized bottlenecks where a platform team manages all routing (slow). +In organizations with multiple teams, the question of _who manages what_ becomes critical. With Ingress, there's no natural separation — anyone with permission to create Ingress resources in a namespace can affect cluster-wide routing behavior. This leads to either overly permissive access (risky) or centralized bottlenecks where a platform team manages all routing (slow). Gateway API's layered architecture maps naturally to organizational boundaries. Each layer corresponds to a different team with different responsibilities and different access levels. @@ -429,19 +441,22 @@ Gateway API's layered architecture maps naturally to organizational boundaries. The three-layer model creates clear ownership: @@ -504,6 +519,7 @@ This separation is enforced at the Kubernetes API level, not just by convention. @@ -525,22 +541,25 @@ Assuming you've already decided Gateway API is the right fit (see the Decision F __Timing signals that suggest waiting:__ configuration-snippet annotation that doesn\'t have a clear Gateway API equivalent', }, { - text: 'Your CI/CD pipeline doesn\'t have good staging coverage for routing changes', + lead: 'Your CI / CD pipeline doesn\'t have good staging coverage for routing changes', }, ]} /> @@ -548,25 +567,28 @@ __Timing signals that suggest waiting:__ __Prerequisite checklist before starting:__ @@ -579,9 +601,15 @@ Migration timelines depend heavily on annotation complexity. If your Ingress res The safest migration runs both APIs in parallel, migrating routes one at a time with the ability to roll back each change independently. -__Phase 1: Parallel operation (2-4 weeks)__ - -Deploy the GatewayClass and Gateway resources, but only create HTTPRoutes for __new__ services. Your existing Ingress resources continue to handle existing traffic. This phase validates that your Gateway setup works without risking existing routes. + ```yaml title="parallel-operation.yaml" # New API version uses Gateway API @@ -612,7 +640,8 @@ Code: New routes use Gateway API while existing Ingress handles legacy paths. This coexistence works because Ingress and Gateway API are separate resource types that controllers watch independently. When a request arrives, the controller checks both APIs and routes based on the matching rules. Requests to `/v2` match the HTTPRoute; requests to `/v1` match the Ingress. There's no conflict because they handle different paths. If you need to migrate an existing path, create the HTTPRoute first with a different hostname for testing, then update DNS or the hostname field to cut over. @@ -749,7 +779,7 @@ Ingress has an 8-year head start. You'll find more blog posts, more examples, an Debugging tools are similar in capability but different in approach. Gateway API resources have richer status conditions that tell you exactly why a route isn't working. Run `kubectl describe httproute ` and look at the `conditions` section: -```text +```bash Status: Parents: Conditions: @@ -766,7 +796,7 @@ This structured feedback beats digging through controller logs — you can see i For GitOps workflows (ArgoCD, Flux), both work natively. The main gotcha with Gateway API is that you need the CRDs installed before ArgoCD tries to sync resources that use them — plan your sync waves accordingly.
    @@ -839,25 +872,28 @@ Example scenarios: a single application with a few endpoints, internal tools wit Choose Gateway API if: @@ -869,7 +905,10 @@ Example scenarios: multi-tenant platforms, deployment pipelines with canary stag You don't have to choose one or the other. Many organizations run both: The best choice is the one that solves your actual problems with acceptable complexity. If Ingress works for you, keep using it. If you need Gateway API's capabilities, it's mature enough for production. Make the decision based on requirements, not hype. + +Both APIs will coexist for years — the Kubernetes networking SIG has confirmed there's no deprecation timeline for Ingress. Migrating purely because Gateway API is "the future" is a poor use of engineering time. Migrate when you have concrete requirements that Gateway API solves better: cross-namespace routing, native traffic splitting, protocol support, or role separation. For greenfield projects where you don't have strong opinions, Gateway API is a reasonable default — it's mature enough for production, and starting there avoids a future migration. diff --git a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx index b94ca02c0..44d0af026 100644 --- a/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx +++ b/src/content/articles/kubernetes-multi-cluster-fleet-management-configuration/index.mdx @@ -13,17 +13,17 @@ featured: true *[GitOps]: Git Operations *[K8s]: Kubernetes -A platform team I worked with managed 15 Kubernetes clusters across dev, staging, and three production regions. They'd started with infrastructure-as-code, consistent tooling, and documented architecture. Two years later, hotfixes had been applied to some clusters and not others. "Temporary" manual changes became permanent. Someone upgraded the service mesh in US-East but forgot US-West. The clusters that started identical had become 15 unique configurations — and nobody could confidently say what was __intentionally__ different versus what had __accidentally__ drifted. +A platform team I worked with managed 15 Kubernetes clusters across dev, staging, and three production regions. They'd started with infrastructure-as-code, consistent tooling, and documented architecture. Two years later, hotfixes had been applied to some clusters and not others. "Temporary" manual changes became permanent. Someone upgraded the service mesh in US-East but forgot US-West. The clusters that started identical had become 15 unique configurations — and nobody could confidently say what was _intentionally_ different versus what had _accidentally_ drifted. -This is the core multi-cluster problem: not deployment (that's straightforward), but __consistency__. How do you know what's supposed to be the same across clusters? How do you detect when drift occurs? The answer is GitOps-based fleet management with automated drift detection. The practical questions are which tools to use — and how to prevent drift once you've deployed them. +This is the core multi-cluster problem: not deployment (that's straightforward), but _consistency_. How do you know what's supposed to be the same across clusters? How do you detect when drift occurs? The answer is GitOps-based fleet management with automated drift detection. The practical questions are which tools to use — and how to prevent drift once you've deployed them. ## ArgoCD vs Flux: Two Models for Multi-Cluster -The two dominant GitOps tools for multi-cluster management are ArgoCD (with ApplicationSets) and Flux. Both store desired state in Git and reconcile clusters toward that state. They differ fundamentally in __how__ they handle multi-cluster targeting. Let's start with ArgoCD's centralized approach, then contrast it with Flux's distributed model. +The two dominant GitOps tools for multi-cluster management are ArgoCD (with ApplicationSets) and Flux. Both store desired state in Git and reconcile clusters toward that state. They differ fundamentally in _how_ they handle multi-cluster targeting. Let's start with ArgoCD's centralized approach, then contrast it with Flux's distributed model. ### ArgoCD ApplicationSets: Centralized Generation -ArgoCD ApplicationSets use a centralized model. A single ApplicationSet controller generates multiple ArgoCD Applications — one per target cluster — from a template. The __generator__ produces parameters (cluster names, environments, regions), and the __template__ stamps out Applications using those parameters. +ArgoCD ApplicationSets use a centralized model. A single ApplicationSet controller generates multiple ArgoCD Applications — one per target cluster — from a template. The _generator_ produces parameters (cluster names, environments, regions), and the _template_ stamps out Applications using those parameters. The cluster generator is the most common pattern. It queries ArgoCD's registered clusters, filters by labels, and creates one Application per matching cluster: @@ -71,26 +71,66 @@ Flux takes the opposite approach. Instead of a central controller generating App The typical pattern uses three layers: base configurations (shared across all clusters), environment overlays (dev/staging/production), and cluster-specific directories. Each cluster's Flux installation points to its own path in the repo: -```text -platform-config/ -├── base/ -│ └── platform/ -│ ├── kustomization.yaml -│ ├── deployment.yaml -│ └── service.yaml -├── overlays/ -│ ├── development/ -│ ├── staging/ -│ └── production/ -│ └── kustomization.yaml -└── clusters/ - ├── us-east-prod/ - ├── us-west-prod/ - └── eu-west-prod/ - └── kustomization.yaml -``` - -Figure: Repository structure for Flux multi-cluster configuration. + Flux Kustomizations (not to be confused with Kustomize's kustomization.yaml) define dependencies between layers. Base configs apply first, then environment overlays, then cluster-specific configs: @@ -117,10 +157,10 @@ Code: Flux Kustomization with cluster-specific value substitution. The `postBuild.substituteFrom` feature injects cluster-specific values from ConfigMaps at reconciliation time — each cluster maintains its own `cluster-values` ConfigMap with region, replica counts, and other parameters. -Flux's model works well when you think in terms of "configuration layers that build on each other." It scales better than ArgoCD for very large fleets (1000+ clusters) because there's no central controller bottleneck. The trade-off is less centralized visibility — there's no built-in UI showing fleet-wide status. - +Flux's model works well when you think in terms of "configuration layers that build on each other." It scales better than ArgoCD for very large fleets (1000+ clusters) because there's no central controller bottleneck. The trade-off is less centralized visibility — there's no built-in UI showing fleet-wide status. + ### Which to Choose? The decision isn't about features — both tools can handle most multi-cluster scenarios. It's primarily about mental model. If you think "deploy this app to these clusters," ArgoCD ApplicationSets match that framing. If you think "base config plus environment overlay plus cluster tweaks," Flux's Kustomization hierarchy fits better. @@ -154,7 +194,7 @@ Auto-remediation is powerful but dangerous. If someone made a legitimate emergen ### Prevention with Admission Control -Detection tells you __that__ drift happens. Prevention stops it from happening. Kubernetes admission controllers can intercept API requests before resources are created or modified, rejecting changes that violate fleet policies. +Detection tells you _that_ drift happens. Prevention stops it from happening. Kubernetes admission controllers can intercept API requests before resources are created or modified, rejecting changes that violate fleet policies. The two main policy engines are OPA/Gatekeeper[^opa-gatekeeper] and Kyverno[^kyverno]. Here's a Kyverno policy that rejects changes to production resources without GitOps annotations: @@ -193,7 +233,10 @@ Drift prevention through admission control is powerful but can block emergency c The tools only work if you've done the organizational groundwork: The hardest part of multi-cluster isn't deploying to many clusters — it's knowing what's supposed to be the same vs. different, and detecting when drift occurs. Without this clarity, you're managing independent configurations, not a fleet. +The multi-cluster management problem isn't primarily about tools. It's about the operating model: how you balance consistency with environment-specific flexibility, how you detect when drift occurs, and how you maintain clarity about what's _supposed_ to be the same versus what's _supposed_ to be different. This article covers the patterns and tools that make fleet management tractable: configuration templating strategies, GitOps-based deployment, drift detection, and progressive rollout across clusters. + ## A Brief History: Why Not Federation? Kubernetes Federation (KubeFed) was the original attempt to solve multi-cluster management. The idea was appealing: a control plane that could deploy resources across multiple clusters from a single API. You'd create a "FederatedDeployment" and it would propagate to member clusters automatically. -In practice, Federation never gained traction. The v1 implementation was deprecated in 2018, and KubeFed v2 saw limited adoption before being archived in 2022. The problems were fundamental: Federation tried to abstract away cluster differences, but real-world multi-cluster deployments __need__ those differences. A one-size-fits-all API couldn't handle the nuanced configuration variations between dev and production, between AWS and on-prem, between US-East and EU-West. +In practice, Federation never gained traction. The v1 implementation was deprecated in 2018, and KubeFed v2 saw limited adoption before being archived in 2022. The problems were fundamental: Federation tried to abstract away cluster differences, but real-world multi-cluster deployments _need_ those differences. A one-size-fits-all API couldn't handle the nuanced configuration variations between dev and production, between AWS and on-prem, between US-East and EU-West. The industry moved toward a different model: GitOps-based fleet management. Instead of a control plane pushing identical configs everywhere, each cluster pulls its configuration from Git, with templating systems handling the variations. This approach — implemented by tools like ArgoCD ApplicationSets, Flux, and Rancher Fleet — provides the consistency benefits Federation promised while preserving the flexibility real deployments require. @@ -43,32 +43,33 @@ The industry moved toward a different model: GitOps-based fleet management. Inst ### Common Patterns -Organizations arrive at multi-cluster architectures for different reasons, and the reason shapes the configuration strategy. Understanding __why__ you have multiple clusters determines what should be consistent versus different across them. +Organizations arrive at multi-cluster architectures for different reasons, and the reason shapes the configuration strategy. Understanding _why_ you have multiple clusters determines what should be consistent versus different across them. @@ -78,7 +79,7 @@ Organizations arrive at multi-cluster architectures for different reasons, and t Every multi-cluster strategy sits somewhere on a spectrum from "all clusters are identical" to "each cluster is independent." Neither extreme works well in practice. @@ -144,33 +146,37 @@ The fleet management landscape has matured around a few dominant approaches. All ### Tool Comparison
    @@ -391,7 +438,7 @@ ArgoCD ApplicationSets excel at generating many similar applications with cluste ## Configuration Templating -Once you've chosen a fleet management tool, the next decision is how to generate cluster-specific configurations from shared definitions. The fleet tool handles __where__ configs deploy; the templating strategy handles __what__ varies between clusters. +Once you've chosen a fleet management tool, the next decision is how to generate cluster-specific configurations from shared definitions. The fleet tool handles _where_ configs deploy; the templating strategy handles _what_ varies between clusters. The two dominant approaches are Kustomize (patch-based) and Helm (template-based), though alternatives like Jsonnet[^jsonnet] and CUE[^cue] exist for organizations with more complex needs. @@ -401,6 +448,7 @@ The two dominant approaches are Kustomize (patch-based) and Helm (template-based @@ -426,6 +474,8 @@ commonLabels: Code: Base kustomization with shared resources. +The production overlay references the base and layers on deployment-specific settings — replica counts, pinned image tags, resource requests and limits, and merged ConfigMap values. Kustomize's `patches` field uses JSON Patch operations to surgically modify individual fields without rewriting the entire manifest. + ```yaml title="kustomize-production-overlay.yaml" # Production overlay (overlays/production/kustomization.yaml) apiVersion: kustomize.config.k8s.io/v1beta1 @@ -463,6 +513,8 @@ configMapGenerator: Code: Production overlay with resource limits and config. +The final layer targets a specific cluster. It references the production overlay and adds region-aware patches — injecting the correct database endpoint and constraining pod scheduling to the matching availability zone via `nodeSelector`. + ```yaml title="kustomize-cluster-overlay.yaml" # Cluster-specific (clusters/us-east-prod/kustomization.yaml) apiVersion: kustomize.config.k8s.io/v1beta1 @@ -516,6 +568,8 @@ config: Code: Base Helm values for all environments. +The production values file overrides the base defaults with deployment-grade settings — higher replica counts, a pinned image tag, explicit resource limits, and a PodDisruptionBudget to maintain availability during rolling updates. + ```yaml title="helm-production-values.yaml" # Production values (values/production.yaml) replicaCount: 5 @@ -539,6 +593,8 @@ podDisruptionBudget: Code: Production values with higher resources and PDB. +The cluster-specific file adds the final layer of customization — scaling replicas for a high-traffic region, wiring in the regional database endpoint, and using `nodeSelector` and `tolerations` to pin workloads to dedicated node pools. + ```yaml title="helm-cluster-values.yaml" # Cluster-specific values (values/clusters/us-east-prod.yaml) replicaCount: 8 # Higher traffic region @@ -556,7 +612,7 @@ tolerations: Code: Cluster-specific values for high-traffic region. -Helmfile orchestrates this layering, merging values files in order and injecting environment variables for cluster-specific paths: +Helmfile orchestrates this layering, merging values files in order and injecting environment variables for cluster-specific paths. ```yaml title="helmfile.yaml" environments: @@ -582,8 +638,10 @@ releases: Code: Helmfile for multi-cluster deployment orchestration. +The key thing to get right with layered values is merge order. Helm performs a shallow merge by default, so nested maps in a later values file replace — rather than merge with — the same key from an earlier file. If your base defines `resources.requests.cpu` and a cluster file only sets `resources.requests.memory`, you lose the CPU request entirely. Use Helmfile's `mergeStrategy: strategic` or restructure values to keep overrides at leaf keys to avoid silent misconfigurations that only surface under load. +
    Fleet management maturity is measured by how confidently you can answer: "What's different between these clusters, and is that difference intentional?" If you can't answer quickly, start with documentation. Tools amplify good processes; they can't substitute for them. + +One area this article hasn't covered in depth is secrets management across clusters — synchronizing credentials, certificates, and API keys while maintaining proper access controls. Tools like External Secrets Operator, Sealed Secrets, and HashiCorp Vault each solve this differently. That's a topic for its own deep dive. diff --git a/src/styles/code-highlighting/general.css b/src/styles/code-highlighting/general.css index a7286b02a..60bf0511a 100644 --- a/src/styles/code-highlighting/general.css +++ b/src/styles/code-highlighting/general.css @@ -11,6 +11,10 @@ * We keep line numbers in a fixed gutter so wrapped lines align under the code, * not under the line number column. */ +pre.shiki { + overflow: hidden; +} + pre.shiki code { counter-increment: step calc(var(--start, 1) - 1); counter-reset: step;