Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion content/blog/de/dev-blog-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/blog/de/plugins-open-for-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion content/blog/en/dev-blog-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion content/blog/en/when-a-server-fails-cluster-resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
221 changes: 221 additions & 0 deletions tests/content/internal-links.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

export interface Link { file: string, locale: string | undefined, href: string }

export interface Target { locale: string | undefined, path: string }

export interface RouteTable {
staticPaths: Set<string>
/** Prefix such as `blog` or `blog/author` -> locale -> slugs. */
dynamic: Map<string, Map<string, Set<string>>>
}

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<string> {
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<string> {
const slugs = new Set<string>()
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<string, (locale: string) => Set<string>> = {
'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<string>()
const dynamic = new Map<string, Map<string, Set<string>>>()
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([])
})
})
Loading