diff --git a/content/blog/de/dev-blog-1.md b/content/blog/de/dev-blog-1.md index 5f5e3a6a..8ac07d08 100644 --- a/content/blog/de/dev-blog-1.md +++ b/content/blog/de/dev-blog-1.md @@ -49,7 +49,7 @@ Technologien (Services): - Gitlab * - OpenProject - Outline -- Kubernetes +- [Kubernetes](https://onelitefeather.net/de/blog/resilienz-im-detail-priorityclasses-pdb-affinitaeten) - NextCloud - MailCow - MariaDB diff --git a/content/blog/de/plugins-open-for-adoption.md b/content/blog/de/plugins-open-for-adoption.md index 5575706f..c0c0f7ac 100644 --- a/content/blog/de/plugins-open-for-adoption.md +++ b/content/blog/de/plugins-open-for-adoption.md @@ -70,7 +70,7 @@ Aufwand: Gering (ca. 1 Stunde wo Woche, max. 4 bei Minecraft/Renovate Update) Sprache: Kotlin Beschreibung: Dieses Plugin gibt den Nutzern auf einem Server die Möglichkeit sich wie ein Aufzug hoch und runter zu bewegen. -## AntiRedstoneClock-Remastered +## [AntiRedstoneClock-Remastered](https://onelitefeather.net/de/projects/anti-redstoneclock-remastered) Github: https://github.com/OneLiteFeatherNET/AntiRedstoneClock-Remastered Schwierigkeit: Mittel diff --git a/content/blog/de/resilienz-im-detail-priorityclasses-pdb-affinitaeten.md b/content/blog/de/resilienz-im-detail-priorityclasses-pdb-affinitaeten.md index 01f4c20e..0c50710a 100644 --- a/content/blog/de/resilienz-im-detail-priorityclasses-pdb-affinitaeten.md +++ b/content/blog/de/resilienz-im-detail-priorityclasses-pdb-affinitaeten.md @@ -86,7 +86,7 @@ spec: app.kubernetes.io/name: bluemap ``` -Solche PDBs habe ich für alle mehrfach replizierten Dienste eingezogen: BlueMap (3), Dependency-Track-Frontend (3), Harbor-Komponenten core/registry/jobservice/portal (je 2), Reposilite (3) und den Prometheus-Agent (2). Bewusst **kein** PDB bekamen Single-Replica-Workloads – ein `maxUnavailable: 1` bei nur einer Replica würde Node-Drains komplett blockieren. Weil die zugrunde liegenden Helm-Charts oft kein natives PDB anbieten, liegen diese als eigenständige Manifeste neben den Releases. +Solche PDBs habe ich für alle mehrfach replizierten Dienste eingezogen: [BlueMap](https://onelitefeather.net/de/bluemap) (3), Dependency-Track-Frontend (3), Harbor-Komponenten core/registry/jobservice/portal (je 2), Reposilite (3) und den Prometheus-Agent (2). Bewusst **kein** PDB bekamen Single-Replica-Workloads – ein `maxUnavailable: 1` bei nur einer Replica würde Node-Drains komplett blockieren. Weil die zugrunde liegenden Helm-Charts oft kein natives PDB anbieten, liegen diese als eigenständige Manifeste neben den Releases. > **Fallbeispiel.** Beim Drain von `fr01-wrk-xl-01` für Wartungsarbeiten wären ohne PDB potenziell mehrere BlueMap-Replicas gleichzeitig umgezogen – die Kartenansicht für einen Moment komplett weg. Mit `maxUnavailable: 1` zieht Kubernetes die Replicas nacheinander um; mindestens zwei bleiben jederzeit erreichbar. @@ -103,7 +103,7 @@ Storage und Datenbanken sind damit strukturell geschützt; verzichtbare Apps tre ### Stolperstein 1: Der stillschweigende No-Op -Die Zuweisung lief gut – bis sie es nicht mehr tat. Bei einigen unserer Charts (Outline, Leantime, Shlink, Reposilite, Otis, BlueMap) trug ich brav `priorityClassName` als Helm-Value ein – und nichts geschah. Diese Charts referenzieren `.Values.priorityClassName` schlicht nicht; der Wert war ein stiller No-Op. Kein Fehler, keine Warnung – einfach wirkungslos. +Die Zuweisung lief gut – bis sie es nicht mehr tat. Bei einigen unserer Charts (Outline, Leantime, Shlink, Reposilite, [Otis](https://onelitefeather.net/de/blog/otis-zentrale-spielerstammdaten-minecraft), BlueMap) trug ich brav `priorityClassName` als Helm-Value ein – und nichts geschah. Diese Charts referenzieren `.Values.priorityClassName` schlicht nicht; der Wert war ein stiller No-Op. Kein Fehler, keine Warnung – einfach wirkungslos. Die Lösung waren HelmRelease-`postRenderers`, die das gerenderte Deployment direkt patchen: diff --git a/content/blog/de/wenn-ein-server-ausfaellt-cluster-resilienz.md b/content/blog/de/wenn-ein-server-ausfaellt-cluster-resilienz.md index b3d9a005..6be789ca 100644 --- a/content/blog/de/wenn-ein-server-ausfaellt-cluster-resilienz.md +++ b/content/blog/de/wenn-ein-server-ausfaellt-cluster-resilienz.md @@ -70,7 +70,7 @@ Das dritte Bild: Stell dir einen Stromausfall zu Hause vor, und du hast nur noch Genau diese Rangordnung habe ich dem Cluster gegeben. Wird der Platz knapp, weiß die Maschine von selbst, was sie schützen muss: den Speicher, auf dem alle Daten liegen, und die Datenbanken, von denen alles abhängt. Und was tritt zuerst zurück? Die netten, aber verzichtbaren Dinge – etwa unsere Minecraft-Kartenansicht. Niemand muss nachts aufstehen und entscheiden; die Reihenfolge steht vorher fest. -> **Ein echtes Beispiel aus unserem Cluster.** Als wir neulich einen Server für ein Update kurz herausgenommen haben, blieb unsere Minecraft-Kartenansicht (BlueMap) durchgehend erreichbar – weil immer mindestens zwei Kopien auf anderen Servern weiterliefen. Vor dem Umbau wäre sie in genau diesem Moment kurz verschwunden. +> **Ein echtes Beispiel aus unserem Cluster.** Als wir neulich einen Server für ein Update kurz herausgenommen haben, blieb unsere Minecraft-Kartenansicht ([BlueMap](https://onelitefeather.net/de/bluemap)) durchgehend erreichbar – weil immer mindestens zwei Kopien auf anderen Servern weiterliefen. Vor dem Umbau wäre sie in genau diesem Moment kurz verschwunden. ## Die Höhen – und die Tiefen diff --git a/content/blog/en/dev-blog-1.md b/content/blog/en/dev-blog-1.md index 383d3f85..0db42779 100644 --- a/content/blog/en/dev-blog-1.md +++ b/content/blog/en/dev-blog-1.md @@ -47,7 +47,7 @@ Technologies (Services): - Gitlab * - OpenProject - Outline -- Kubernetes +- [Kubernetes](https://onelitefeather.net/en/blog/resilience-in-detail-priorityclasses-pdb-affinities) - NextCloud - MailCow - MariaDB diff --git a/content/blog/en/resilience-in-detail-priorityclasses-pdb-affinities.md b/content/blog/en/resilience-in-detail-priorityclasses-pdb-affinities.md index 99274c8b..ff590d81 100644 --- a/content/blog/en/resilience-in-detail-priorityclasses-pdb-affinities.md +++ b/content/blog/en/resilience-in-detail-priorityclasses-pdb-affinities.md @@ -82,7 +82,7 @@ spec: app.kubernetes.io/name: bluemap ``` -I added PDBs like this for every multi-replica service: BlueMap (3), Dependency-Track frontend (3), Harbor components core/registry/jobservice/portal (2 each), Reposilite (3) and the Prometheus agent (2). I deliberately gave single-replica workloads **no** PDB – a `maxUnavailable: 1` on a single replica would block node drains entirely. Because the underlying Helm charts often offer no native PDB, these live as standalone manifests next to the releases. +I added PDBs like this for every multi-replica service: [BlueMap](https://onelitefeather.net/en/bluemap) (3), Dependency-Track frontend (3), Harbor components core/registry/jobservice/portal (2 each), Reposilite (3) and the Prometheus agent (2). I deliberately gave single-replica workloads **no** PDB – a `maxUnavailable: 1` on a single replica would block node drains entirely. Because the underlying Helm charts often offer no native PDB, these live as standalone manifests next to the releases. > **Case in point.** Draining `fr01-wrk-xl-01` for maintenance could, without a PDB, have moved several BlueMap replicas at once – the map viewer gone for a beat. With `maxUnavailable: 1`, Kubernetes moves the replicas one after another; at least two stay reachable throughout. @@ -99,7 +99,7 @@ Storage and databases are thereby structurally protected; expendable apps step b ### Pitfall 1: the silent no-op -Assignment went well – until it didn't. For some of our charts (Outline, Leantime, Shlink, Reposilite, Otis, BlueMap) I dutifully set `priorityClassName` as a Helm value – and nothing happened. Those charts simply don't reference `.Values.priorityClassName`; the value was a silent no-op. No error, no warning – just inert. +Assignment went well – until it didn't. For some of our charts (Outline, Leantime, Shlink, Reposilite, [Otis](https://onelitefeather.net/en/blog/otis-central-player-data-minecraft), BlueMap) I dutifully set `priorityClassName` as a Helm value – and nothing happened. Those charts simply don't reference `.Values.priorityClassName`; the value was a silent no-op. No error, no warning – just inert. The fix was HelmRelease `postRenderers` patching the rendered Deployment directly: diff --git a/content/blog/en/when-a-server-fails-cluster-resilience.md b/content/blog/en/when-a-server-fails-cluster-resilience.md index 7659dc5e..18d77463 100644 --- a/content/blog/en/when-a-server-fails-cluster-resilience.md +++ b/content/blog/en/when-a-server-fails-cluster-resilience.md @@ -66,7 +66,7 @@ The third picture: imagine a power shortage at home, only a limited amount of ju That's exactly the pecking order I gave the cluster. When space runs short, the machine knows on its own what to protect: the storage that holds all the data, and the databases everything depends on. And what steps back first? The nice-but-optional things – like our Minecraft map viewer. Nobody has to get up at night and decide; the order is fixed in advance. -> **A real example from our cluster.** When we recently pulled a server out for an update, our Minecraft map viewer (BlueMap) stayed reachable the whole time – because at least two copies kept running on other servers. Before the rework, it would have briefly vanished at exactly that moment. +> **A real example from our cluster.** When we recently pulled a server out for an update, our Minecraft map viewer ([BlueMap](https://onelitefeather.net/en/bluemap)) stayed reachable the whole time – because at least two copies kept running on other servers. Before the rework, it would have briefly vanished at exactly that moment. ## The highs – and the lows diff --git a/tests/content/internal-links.spec.ts b/tests/content/internal-links.spec.ts new file mode 100644 index 00000000..f455b46f --- /dev/null +++ b/tests/content/internal-links.spec.ts @@ -0,0 +1,221 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' +import { collectSourceFiles, relativeToRepo, repoRoot } from '../helpers/sources' +import { locales } from '../../layers/content-core/utils/content/locales' + +/** + * An internal markdown link in content/ must name a page that exists for the + * link's own locale. Nothing in the build checks this: a link to a slug that + * was renamed or never published renders fine and only 404s for the reader. + * + * Static routes come from `pages/`. Dynamic segments come from the slugs the + * app itself queries. A new dynamic route fails until it gets a slug source + * here, so the check cannot quietly stop covering it. + */ + +type Frontmatter = Record + +export interface Link { file: string, locale: string | undefined, href: string } + +export interface Target { locale: string | undefined, path: string } + +export interface RouteTable { + staticPaths: Set + /** Prefix such as `blog` or `blog/author` -> locale -> slugs. */ + dynamic: Map>> +} + +const SITE = 'https://onelitefeather.net' +const ASSET_PREFIX = '/images/' + +function localeOf(file: string): string | undefined { + const segment = file.split('/')[2] + return segment && (locales as readonly string[]).includes(segment) ? segment : undefined +} + +/** Markdown links in the body, images and frontmatter excluded. */ +export function markdownLinks(file: string, text: string): Link[] { + const body = text.startsWith('---') ? text.slice(text.indexOf('\n---', 3) + 4) : text + const locale = localeOf(file) + return [...body.matchAll(/(!?)\[[^\]\n]*\]\(([^)\s]+)\)/g)] + .filter(([, bang]) => bang === '') + .map(([, , href]) => ({ file, locale, href: href! })) +} + +/** The locale and page path a link points to, or undefined for anything outside the site. */ +export function internalTarget(href: string): Target | undefined { + let rest: string + if (href.startsWith(SITE)) rest = href.slice(SITE.length) + else if (href.startsWith('/') && !href.startsWith('//')) rest = href + else return undefined + rest = rest.split(/[?#]/)[0]! + if (rest.startsWith(ASSET_PREFIX)) return undefined + const segments = rest.split('/').filter(Boolean) + const [first, ...others] = segments + if (first !== undefined && (locales as readonly string[]).includes(first)) { + return { locale: first, path: others.join('/') } + } + return { locale: undefined, path: segments.join('/') } +} + +export function pageExists(routes: RouteTable, locale: string, path: string): boolean { + if (routes.staticPaths.has(path)) return true + for (const [prefix, byLocale] of routes.dynamic) { + if (!path.startsWith(`${prefix}/`)) continue + const slug = path.slice(prefix.length + 1) + if (!slug.includes('/') && byLocale.get(locale)?.has(slug)) return true + } + return false +} + +export function unresolvedLinks(links: Link[], routes: RouteTable): string[] { + return links.flatMap((link) => { + const target = internalTarget(link.href) + if (!target) return [] + if (target.locale === undefined) { + return [`${link.file}: ${link.href} has no locale prefix`] + } + if (link.locale !== undefined && target.locale !== link.locale) { + return [`${link.file}: ${link.href} links to ${target.locale} from a ${link.locale} file`] + } + if (!pageExists(routes, target.locale, target.path)) { + return [`${link.file}: ${link.href} does not resolve in ${target.locale}`] + } + return [] + }) +} + +function vueFiles(dir: string, prefix = ''): string[] { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry) + if (statSync(full).isDirectory()) return vueFiles(full, `${prefix}${entry}/`) + return entry.endsWith('.vue') ? [`${prefix}${entry}`] : [] + }) +} + +function frontmatters(collection: string): Array<{ locale: string, data: Frontmatter }> { + return collectSourceFiles([`content/${collection}`], ['.md']).flatMap((file) => { + const locale = localeOf(relativeToRepo(file)) + if (!locale) return [] + const text = readFileSync(file, 'utf8') + const data = (parse(text.slice(3, text.indexOf('\n---', 3))) ?? {}) as Frontmatter + return [{ locale, data }] + }) +} + +function fieldOf(collection: string, field: string): (locale: string) => Set { + return (locale) => new Set(frontmatters(collection) + .filter((doc) => doc.locale === locale && typeof doc.data[field] === 'string') + .map((doc) => doc.data[field] as string)) +} + +function teamSlugs(locale: string): Set { + const slugs = new Set() + for (const file of collectSourceFiles(['content/team'], ['.json'])) { + if (localeOf(relativeToRepo(file)) !== locale) continue + const json = JSON.parse(readFileSync(file, 'utf8')) as { members?: Array<{ slug?: string }> } + for (const member of json.members ?? []) if (member.slug) slugs.add(member.slug) + } + return slugs +} + +/** The slug each dynamic page directory is routed by, keyed by its directory under `pages/`. */ +const SLUG_SOURCES: Record Set> = { + 'blog': fieldOf('blog', 'slug'), + 'blog/author': fieldOf('blog', 'author'), + 'projects': fieldOf('projects', 'slug'), + 'community-poi': fieldOf('community-poi', 'slug'), + 'events': fieldOf('events', 'slug'), + 'team': teamSlugs, +} + +export function buildRouteTable(vuePages: string[]): RouteTable { + const staticPaths = new Set() + const dynamic = new Map>>() + for (const page of vuePages) { + const route = page.replace(/\.vue$/, '').replace(/(^|\/)index$/, '') + if (!route.includes('[')) { + staticPaths.add(route) + continue + } + const prefix = route.slice(0, route.indexOf('[')).replace(/\/$/, '') + const source = SLUG_SOURCES[prefix] + if (!source) throw new Error(`pages/${page} has no slug source in tests/content/internal-links.spec.ts`) + dynamic.set(prefix, new Map(locales.map((locale) => [locale, source(locale)]))) + } + return { staticPaths, dynamic } +} + +describe('internal link targets', () => { + const bySlugs = (de: string[], en: string[]) => new Map([['de', new Set(de)], ['en', new Set(en)]]) + const routes: RouteTable = { + staticPaths: new Set(['', 'bluemap']), + dynamic: new Map([ + ['blog', bySlugs(['post-de'], ['post-en'])], ['blog/author', bySlugs(['ada'], ['ada'])], + ]), + } + const linksIn = (file: string, text: string) => unresolvedLinks(markdownLinks(file, text), routes) + + it('accepts a link to a post that exists in the locale of its file', () => { + expect(linksIn('content/blog/de/a.md', '[x](/de/blog/post-de)')).toEqual([]) + }) + + it('names the file and href when the slug exists only in the other locale', () => { + expect(linksIn('content/blog/de/a.md', '[x](/de/blog/post-en)')) + .toEqual(['content/blog/de/a.md: /de/blog/post-en does not resolve in de']) + }) + + it('flags a link into the other locale even when the target exists', () => { + expect(linksIn('content/blog/de/a.md', '[x](/en/blog/post-en)')) + .toEqual(['content/blog/de/a.md: /en/blog/post-en links to en from a de file']) + }) + + it('accepts the absolute onelitefeather.net form used by the cluster posts', () => { + expect(linksIn('content/blog/de/a.md', '[x](https://onelitefeather.net/de/blog/post-de)')).toEqual([]) + }) + + it('resolves static pages and author pages', () => { + expect(linksIn('content/faq/en/b.md', '[x](/en/bluemap) [y](/en/blog/author/ada)')).toEqual([]) + }) + + it('ignores images, external links and in-page anchors', () => { + expect(linksIn('content/blog/de/a.md', '![a](/images/x.png) [y](https://example.com/de/blog/nope) [z](#top)')).toEqual([]) + }) + + it('compares the path without its query string or fragment', () => { + expect(linksIn('content/blog/de/a.md', '[x](/de/blog/post-de#intro)')).toEqual([]) + }) + + it('flags a root-relative link without a locale prefix', () => { + expect(linksIn('content/blog/de/a.md', '[x](/blog/post-de)')) + .toEqual(['content/blog/de/a.md: /blog/post-de has no locale prefix']) + }) + + it('reads only the body, not the frontmatter', () => { + const text = '---\ncanonical: \'https://onelitefeather.net/de/blog/missing\'\n---\nNo links here.' + expect(markdownLinks('content/blog/de/a.md', text)).toEqual([]) + }) + + it('names a page that does not exist', () => { + expect(linksIn('content/blog/en/a.md', '[x](/en/blog/missing)')) + .toEqual(['content/blog/en/a.md: /en/blog/missing does not resolve in en']) + }) +}) + +describe('internal links in the content files', () => { + const routes = buildRouteTable(vueFiles(join(repoRoot, 'pages'))) + const links = collectSourceFiles(['content'], ['.md']) + .flatMap((file) => markdownLinks(relativeToRepo(file), readFileSync(file, 'utf8'))) + + it('reads the routes and the links it checks', () => { + expect(routes.staticPaths.has('bluemap')).toBe(true) + expect(routes.dynamic.has('blog')).toBe(true) + expect(links.length).toBeGreaterThan(5) + }) + + it('resolves every internal link to a page of its own locale', () => { + expect(unresolvedLinks(links, routes)).toEqual([]) + }) +})