1 Commits
31 changed files with 372 additions and 27 deletions
+52
View File
@@ -16,8 +16,40 @@ env:
KUBE_API_SERVER: https://100.112.189.33:6443/ KUBE_API_SERVER: https://100.112.189.33:6443/
jobs: jobs:
verify:
name: Verify application and manifests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Test
run: bun run test
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Set up kubectl
uses: azure/setup-kubectl@v4
- name: Validate Kubernetes manifests
run: kubectl kustomize k8s/ >/dev/null
build-and-deploy: build-and-deploy:
name: Build immutable images and deploy name: Build immutable images and deploy
needs: verify
if: >- if: >-
gitea.event_name == 'push' && gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main' && gitea.ref == 'refs/heads/main' &&
@@ -40,6 +72,20 @@ jobs:
. .
docker push ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }} docker push ${{ env.REGISTRY_IMAGE }}:${{ gitea.sha }}
- name: Build and push public mirror image
run: |
docker build \
--target app \
--build-arg BASE_URL="$BASE_URL" \
--build-arg NEXT_PUBLIC_BASE_PATH=/guide \
--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID" \
--build-arg PUBLIC_MIRROR=true \
--build-arg NEXT_DEPLOYMENT_ID=${{ gitea.sha }} \
--build-arg VCS_REF=${{ gitea.sha }} \
--tag ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }} \
.
docker push ${{ env.REGISTRY_IMAGE }}:mirror-${{ gitea.sha }}
- name: Build and push migration image - name: Build and push migration image
run: | run: |
docker build \ docker build \
@@ -138,6 +184,9 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet \ deployment/buzz-sheet \
app="$REGISTRY_IMAGE:$REVISION" app="$REGISTRY_IMAGE:$REVISION"
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet-mirror \
app="$REGISTRY_IMAGE:mirror-$REVISION"
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet-worker \ deployment/buzz-sheet-worker \
worker="$REGISTRY_IMAGE:$REVISION" worker="$REGISTRY_IMAGE:$REVISION"
@@ -147,6 +196,9 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet \ deployment/buzz-sheet \
--timeout=10m --timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-mirror \
--timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \ kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-worker \ deployment/buzz-sheet-worker \
--timeout=10m --timeout=10m
+4
View File
@@ -23,10 +23,14 @@ FROM node:22-bookworm-slim AS next-builder
WORKDIR /app WORKDIR /app
ARG NEXT_DEPLOYMENT_ID=container-build ARG NEXT_DEPLOYMENT_ID=container-build
ARG BASE_URL ARG BASE_URL
ARG NEXT_PUBLIC_BASE_PATH
ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
ARG PUBLIC_MIRROR=false
ENV BASE_URL=${BASE_URL} ENV BASE_URL=${BASE_URL}
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID} ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID}
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID} ENV NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID}
ENV PUBLIC_MIRROR=${PUBLIC_MIRROR}
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules COPY --from=dependencies /app/node_modules ./node_modules
COPY . . COPY . .
+13 -2
View File
@@ -3,7 +3,8 @@
Buzz Sheet is a Thai-first guide CMS for turning structured character, build, Buzz Sheet is a Thai-first guide CMS for turning structured character, build,
team, rotation, and comparison data into responsive game-guide pages. team, rotation, and comparison data into responsive game-guide pages.
Production target: `https://guide.sudloh.com` Production targets: `https://guide.sudloh.com` and the public-only mirror at
`https://buzz.sudloh.com/guide`.
This project is an independent community tool for Genshin Impact players. It is This project is an independent community tool for Genshin Impact players. It is
not affiliated with or endorsed by HoYoverse. not affiliated with or endorsed by HoYoverse.
@@ -181,7 +182,8 @@ Kustomize resources live in `k8s/` and define:
- Namespace `buzz-sheet`. - Namespace `buzz-sheet`.
- Two rolling web replicas, one outbox worker, and one Discord catalog worker. - Two rolling web replicas, one outbox worker, and one Discord catalog worker.
- ClusterIP port 3000 and Traefik ingress for `guide.sudloh.com`. - Separate root and `/guide` web workloads with Traefik ingress for
`guide.sudloh.com` and `buzz.sudloh.com/guide`.
- Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route. - Cloudflare edge TLS with the cluster's standard HTTP Traefik origin route.
- Startup, liveness, and dependency-aware readiness probes. - Startup, liveness, and dependency-aware readiness probes.
- Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB. - Web requests of 500m CPU/1 GiB and limits of 1 CPU/2 GiB.
@@ -219,12 +221,21 @@ Every page also subscribes to `/api/active`. When a serving pod reports a newer
deployment ID than the browser's current build, a persistent Thai notification deployment ID than the browser's current build, a persistent Thai notification
offers a full reload into the new release. offers a full reload into the new release.
The `buzz.sudloh.com/guide` build sets `NEXT_PUBLIC_BASE_PATH=/guide` and
`PUBLIC_MIRROR=true`. It shares public data with the canonical site but returns
404 for admin, registration, authentication, and admin API routes. The original
`guide.sudloh.com` build and its admin routes remain unchanged.
The base manifests contain deliberate `replace-me` image and deployment values. The base manifests contain deliberate `replace-me` image and deployment values.
For initial bootstrap, the platform operator applies `k8s/base` through an For initial bootstrap, the platform operator applies `k8s/base` through an
environment overlay that supplies an existing immutable image revision. The environment overlay that supplies an existing immutable image revision. The
aggregate `k8s/` kustomization includes the migration resource for rendering and aggregate `k8s/` kustomization includes the migration resource for rendering and
validation; normal releases create their own revision-named migration Job. validation; normal releases create their own revision-named migration Job.
The public mirror adds new Deployment, Service, HPA, PDB, and Ingress resources.
A cluster administrator must apply those base resources once before the first CI
release that attempts to roll out `deployment/buzz-sheet-mirror`.
Use this command for local manifest validation only: Use this command for local manifest validation only:
```bash ```bash
+3 -1
View File
@@ -4,18 +4,20 @@ import { notFound } from "next/navigation";
import { GoogleAdsense } from "@/components/public/google-adsense"; import { GoogleAdsense } from "@/components/public/google-adsense";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide"; import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public"; import { getCachedPublicGuide } from "@/lib/cache/public";
import { siteUrl } from "@/lib/site-url";
type Params = Promise<{ character: string; page: string }>; type Params = Promise<{ character: string; page: string }>;
export const instant = false; export const instant = false;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { character } = await params; const { character, page } = await params;
const guide = await getCachedPublicGuide(character); const guide = await getCachedPublicGuide(character);
return guide return guide
? { ? {
title: `${guide.name}`, title: `${guide.name}`,
description: guide.overview, description: guide.overview,
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}/${encodeURIComponent(page)}` },
openGraph: { images: [guide.coverUrl] }, openGraph: { images: [guide.coverUrl] },
twitter: { images: [guide.coverUrl] }, twitter: { images: [guide.coverUrl] },
} }
+2
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { GoogleAdsense } from "@/components/public/google-adsense"; import { GoogleAdsense } from "@/components/public/google-adsense";
import { StructuredGuideShell } from "@/components/public/structured-guide"; import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public"; import { getCachedPublicGuide } from "@/lib/cache/public";
import { siteUrl } from "@/lib/site-url";
type Params = Promise<{ character: string }>; type Params = Promise<{ character: string }>;
@@ -19,6 +20,7 @@ export async function generateMetadata({
? { ? {
title: `${guide.name}`, title: `${guide.name}`,
description: guide.overview, description: guide.overview,
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}` },
openGraph: { images: [guide.coverUrl] }, openGraph: { images: [guide.coverUrl] },
twitter: { images: [guide.coverUrl] }, twitter: { images: [guide.coverUrl] },
} }
+3
View File
@@ -27,6 +27,9 @@ export const metadata: Metadata = {
}, },
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ", description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"], keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"],
robots: process.env.PUBLIC_MIRROR === "true"
? { index: false, follow: false }
: undefined,
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({ children }: LayoutProps<"/">) {
+6
View File
@@ -7,6 +7,12 @@ import { SiteFooter } from "@/components/public/site-footer";
import { StructuredDirectory } from "@/components/public/structured-directory"; import { StructuredDirectory } from "@/components/public/structured-directory";
import { SiteHeader } from "@/components/public/site-header"; import { SiteHeader } from "@/components/public/site-header";
import { getCachedPublicGuideCards } from "@/lib/cache/public"; import { getCachedPublicGuideCards } from "@/lib/cache/public";
import { siteUrl } from "@/lib/site-url";
import type { Metadata } from "next";
export const metadata: Metadata = {
alternates: { canonical: siteUrl },
};
async function DirectoryContent() { async function DirectoryContent() {
await connection(); await connection();
+3
View File
@@ -3,6 +3,9 @@ import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/site-url"; import { siteUrl } from "@/lib/site-url";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
if (process.env.PUBLIC_MIRROR === "true") {
return { rules: { userAgent: "*", disallow: "/" } };
}
return { return {
rules: { rules: {
userAgent: "*", userAgent: "*",
+5 -2
View File
@@ -10,6 +10,8 @@ import {
} from "@/components/ui/avatar"; } from "@/components/ui/avatar";
import { SiteFooter } from "@/components/public/site-footer"; import { SiteFooter } from "@/components/public/site-footer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { withBasePath } from "@/lib/base-path";
import { siteUrl } from "@/lib/site-url";
import { import {
Card, Card,
CardContent, CardContent,
@@ -57,6 +59,7 @@ const specialThanks: ThanksProfile[] = [
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Special Thanks", title: "Special Thanks",
description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้", description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้",
alternates: { canonical: `${siteUrl}/thanks` },
}; };
function getInitials(username: string) { function getInitials(username: string) {
@@ -73,7 +76,7 @@ export default function ThanksPage() {
className="flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Image <Image
src="/icon.png" src={withBasePath("/icon.png")}
alt="" alt=""
width={36} width={36}
height={36} height={36}
@@ -97,7 +100,7 @@ export default function ThanksPage() {
<Card className="w-full gap-0 py-0 sm:flex-row sm:items-center"> <Card className="w-full gap-0 py-0 sm:flex-row sm:items-center">
<CardHeader className="grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1 py-4 text-left sm:flex-1"> <CardHeader className="grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1 py-4 text-left sm:flex-1">
<Image <Image
src="/thanks/astral.png" src={withBasePath("/thanks/astral.png")}
alt="Astral Development alt="Astral Development
" "
width={128} width={128}
+2 -1
View File
@@ -7,6 +7,7 @@ import {
hasDeploymentChanged, hasDeploymentChanged,
normalizeDeploymentId, normalizeDeploymentId,
} from "@/lib/deployment/version"; } from "@/lib/deployment/version";
import { withBasePath } from "@/lib/base-path";
const UPDATE_TOAST_ID = "deployment-update"; const UPDATE_TOAST_ID = "deployment-update";
@@ -19,7 +20,7 @@ export function DeploymentUpdateNotifier({
const updateAvailable = useRef(false); const updateAvailable = useRef(false);
useEffect(() => { useEffect(() => {
const source = new EventSource("/api/active"); const source = new EventSource(withBasePath("/api/active"));
const receiveDeployment = (event: Event) => { const receiveDeployment = (event: Event) => {
if ( if (
updateAvailable.current || updateAvailable.current ||
+2 -1
View File
@@ -5,13 +5,14 @@ import { useEffect } from "react";
import { parseInvalidationEvent } from "@/lib/events/invalidation"; import { parseInvalidationEvent } from "@/lib/events/invalidation";
import { createRefreshScheduler } from "@/lib/events/refresh"; import { createRefreshScheduler } from "@/lib/events/refresh";
import { withBasePath } from "@/lib/base-path";
export function LiveRefresh({ endpoint }: { endpoint: string }) { export function LiveRefresh({ endpoint }: { endpoint: string }) {
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
const scheduler = createRefreshScheduler(() => router.refresh()); const scheduler = createRefreshScheduler(() => router.refresh());
const source = new EventSource(endpoint); const source = new EventSource(withBasePath(endpoint));
const connected = () => scheduler.connected(); const connected = () => scheduler.connected();
const notified = (message: Event) => { const notified = (message: Event) => {
if (!(message instanceof MessageEvent) || typeof message.data !== "string") { if (!(message instanceof MessageEvent) || typeof message.data !== "string") {
+2 -1
View File
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { GuideExportBranding } from "@/components/public/guide-export-branding";
import { ExportText } from "@/components/public/export-language"; import { ExportText } from "@/components/public/export-language";
@@ -27,7 +28,7 @@ export function ConstellationImage({
<div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5"> <div className="absolute inset-x-20 top-7 z-10 flex items-start justify-center gap-5">
<Image <Image
src="/icon/nav/Constellations.webp" src={withBasePath("/icon/nav/Constellations.webp")}
alt="" alt=""
width={112} width={112}
height={112} height={112}
+2 -1
View File
@@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
export function GuideExportBranding({ export function GuideExportBranding({
backgroundUrl, backgroundUrl,
@@ -21,7 +22,7 @@ export function GuideExportBranding({
/> />
<div className="absolute inset-0 bg-[#050817]/65" /> <div className="absolute inset-0 bg-[#050817]/65" />
<Image <Image
src="/icon/Watermark.png" src={withBasePath("/icon/Watermark.png")}
alt="" alt=""
width={constellation ? 280 : 180} width={constellation ? 280 : 180}
height={constellation ? 158 : 101} height={constellation ? 158 : 101}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
import Link from "next/link"; import Link from "next/link";
import { useRef, type PointerEvent } from "react"; import { useRef, type PointerEvent } from "react";
@@ -170,7 +171,7 @@ export function MaterialPoster({
</div> </div>
) : null} ) : null}
<Image <Image
src="/img/mat/fade.png" src={withBasePath("/img/mat/fade.png")}
alt="" alt=""
fill fill
sizes={`${POSTER_EXPORT_WIDTH}px`} sizes={`${POSTER_EXPORT_WIDTH}px`}
+3 -1
View File
@@ -5,8 +5,10 @@ import { Suspense } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence"; import { VisitorPresence } from "@/components/public/visitor-presence";
import { getAdminSession } from "@/lib/auth/server"; import { getAdminSession } from "@/lib/auth/server";
import { withBasePath } from "@/lib/base-path";
async function AdminEditLink({ editHref }: { editHref: string }) { async function AdminEditLink({ editHref }: { editHref: string }) {
if (process.env.PUBLIC_MIRROR === "true") return null;
const isAdmin = Boolean(await getAdminSession()); const isAdmin = Boolean(await getAdminSession());
return isAdmin ? ( return isAdmin ? (
@@ -29,7 +31,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
href="/" href="/"
className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="mr-auto flex min-h-11 items-center gap-3 rounded-lg font-heading font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Image src="/icon.png" alt="" width={36} height={36} className="size-9 object-contain" /> <Image src={withBasePath("/icon.png")} alt="" width={36} height={36} className="size-9 object-contain" />
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span> <span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
</Link> </Link>
<nav aria-label="Public navigation" className="flex items-center gap-1"> <nav aria-label="Public navigation" className="flex items-center gap-1">
+7 -6
View File
@@ -43,6 +43,7 @@ import { elementIconFilter } from "@/lib/catalog/element";
import type { PublicGuide } from "@/lib/guides/queries"; import type { PublicGuide } from "@/lib/guides/queries";
import { artifactTierCount } from "@/lib/guides/artifacts"; import { artifactTierCount } from "@/lib/guides/artifacts";
import { navigationIconPath } from "@/lib/navigation"; import { navigationIconPath } from "@/lib/navigation";
import { withBasePath } from "@/lib/base-path";
import { import {
normalizeTargetStat, normalizeTargetStat,
recommendedStatElement, recommendedStatElement,
@@ -419,7 +420,7 @@ function WeaponSectionContent({
</p> </p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Image <Image
src="/icon/nav/Weapon.png" src={withBasePath("/icon/nav/Weapon.png")}
alt="" alt=""
width={56} width={56}
height={56} height={56}
@@ -656,21 +657,21 @@ function ArtifactSectionContent({
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-3 p-0"> <CardContent className="grid grid-cols-3 p-0">
<ArtifactStat <ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp" iconSrc={withBasePath("/icon/Icon_Sands_of_Eon.webp")}
label={exportLabel(exporting, "นาฬิกา", "Sands")} label={exportLabel(exporting, "นาฬิกา", "Sands")}
values={condition.sands} values={condition.sands}
exporting={exporting} exporting={exporting}
elementIconUrls={guide.elementIconUrls} elementIconUrls={guide.elementIconUrls}
/> />
<ArtifactStat <ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp" iconSrc={withBasePath("/icon/Icon_Goblet_of_Eonothem.webp")}
label={exportLabel(exporting, "ถ้วย", "Goblet")} label={exportLabel(exporting, "ถ้วย", "Goblet")}
values={condition.goblet} values={condition.goblet}
exporting={exporting} exporting={exporting}
elementIconUrls={guide.elementIconUrls} elementIconUrls={guide.elementIconUrls}
/> />
<ArtifactStat <ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp" iconSrc={withBasePath("/icon/Icon_Circlet_of_Logos.webp")}
label={exportLabel(exporting, "หัว", "Circlet")} label={exportLabel(exporting, "หัว", "Circlet")}
values={condition.circlet} values={condition.circlet}
exporting={exporting} exporting={exporting}
@@ -721,7 +722,7 @@ function ArtifactSectionContent({
</p> </p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Image <Image
src="/icon/nav/Artifact.webp" src={withBasePath("/icon/nav/Artifact.webp")}
alt="" alt=""
width={56} width={56}
height={56} height={56}
@@ -1155,7 +1156,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p> </p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Image <Image
src="/icon/nav/Constellations.webp" src={withBasePath("/icon/nav/Constellations.webp")}
alt="" alt=""
width={56} width={56}
height={56} height={56}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useEffectEvent, useState } from "react"; import { useEffect, useEffectEvent, useState } from "react";
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants"; import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
import { withBasePath } from "@/lib/base-path";
type VisitorCounts = { type VisitorCounts = {
global: number; global: number;
@@ -51,7 +52,7 @@ function useHeartbeat<T>({
const [value, setValue] = useState<T | null>(null); const [value, setValue] = useState<T | null>(null);
const send = useEffectEvent(async (active = true) => { const send = useEffectEvent(async (active = true) => {
try { try {
const response = await fetch(endpoint, { const response = await fetch(withBasePath(endpoint), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
+6
View File
@@ -55,6 +55,8 @@ Restart all Buzz Sheet workloads after updating the Secret:
```bash ```bash
kubectl rollout restart deployment/buzz-sheet \ kubectl rollout restart deployment/buzz-sheet \
--namespace buzz-sheet --namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-mirror \
--namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-worker \ kubectl rollout restart deployment/buzz-sheet-worker \
--namespace buzz-sheet --namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-discord-worker \ kubectl rollout restart deployment/buzz-sheet-discord-worker \
@@ -67,6 +69,9 @@ Wait for every rollout to finish:
kubectl rollout status deployment/buzz-sheet \ kubectl rollout status deployment/buzz-sheet \
--namespace buzz-sheet \ --namespace buzz-sheet \
--timeout=10m --timeout=10m
kubectl rollout status deployment/buzz-sheet-mirror \
--namespace buzz-sheet \
--timeout=10m
kubectl rollout status deployment/buzz-sheet-worker \ kubectl rollout status deployment/buzz-sheet-worker \
--namespace buzz-sheet \ --namespace buzz-sheet \
--timeout=10m --timeout=10m
@@ -79,6 +84,7 @@ kubectl rollout status deployment/buzz-sheet-discord-worker \
```bash ```bash
curl -fsS 'https://guide.sudloh.com/api/health?ready=1' curl -fsS 'https://guide.sudloh.com/api/health?ready=1'
curl -fsS 'https://buzz.sudloh.com/guide/api/health?ready=1'
bun logs bun logs
``` ```
+1 -1
View File
@@ -26,7 +26,7 @@ rules:
verbs: ["get"] verbs: ["get"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments"] resources: ["deployments"]
resourceNames: ["buzz-sheet", "buzz-sheet-worker", "buzz-sheet-discord-worker"] resourceNames: ["buzz-sheet", "buzz-sheet-mirror", "buzz-sheet-worker", "buzz-sheet-discord-worker"]
verbs: ["get", "watch", "patch", "update"] verbs: ["get", "watch", "patch", "update"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["replicasets"] resources: ["replicasets"]
+10
View File
@@ -19,3 +19,13 @@ spec:
name: buzz-sheet name: buzz-sheet
port: port:
number: 3000 number: 3000
- host: buzz.sudloh.com
http:
paths:
- path: /guide
pathType: Prefix
backend:
service:
name: buzz-sheet-mirror
port:
number: 3000
+7
View File
@@ -5,14 +5,21 @@ resources:
- namespace.yaml - namespace.yaml
- configmap.yaml - configmap.yaml
- deployment.yaml - deployment.yaml
- mirror-deployment.yaml
- worker-deployment.yaml - worker-deployment.yaml
- discord-worker-deployment.yaml - discord-worker-deployment.yaml
- service.yaml - service.yaml
- mirror-service.yaml
- ingress.yaml - ingress.yaml
- hpa.yaml - hpa.yaml
- mirror-hpa.yaml
- pdb.yaml - pdb.yaml
- mirror-pdb.yaml
- ci-rbac.yaml - ci-rbac.yaml
images: images:
- name: buzz-sheet-app - name: buzz-sheet-app
newName: registry.neko-piranha.ts.net/astral/buzz-sheet newName: registry.neko-piranha.ts.net/astral/buzz-sheet
newTag: replace-me newTag: replace-me
- name: buzz-sheet-mirror
newName: registry.neko-piranha.ts.net/astral/buzz-sheet
newTag: mirror-replace-me
+100
View File
@@ -0,0 +1,100 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: buzz-sheet-mirror
labels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
spec:
replicas: 2
revisionHistoryLimit: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
template:
metadata:
labels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
nodeSelector:
kubernetes.io/arch: arm64
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: buzz-sheet-mirror
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
protocol: TCP
envFrom:
- configMapRef:
name: buzz-sheet-config
- secretRef:
name: buzz-sheet-env
env:
- name: NEXT_DEPLOYMENT_ID
valueFrom:
configMapKeyRef:
name: buzz-sheet-config
key: NEXT_DEPLOYMENT_ID
- name: PUBLIC_MIRROR
value: "true"
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "1"
memory: 2Gi
startupProbe:
httpGet:
path: /guide/api/health
port: http
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /guide/api/health?ready=1
port: http
failureThreshold: 3
periodSeconds: 10
timeoutSeconds: 4
livenessProbe:
httpGet:
path: /guide/api/health
port: http
failureThreshold: 3
periodSeconds: 20
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: next-cache
mountPath: /app/.next/cache
- name: temporary
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: temporary
emptyDir: {}
+31
View File
@@ -0,0 +1,31 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: buzz-sheet-mirror
labels:
app.kubernetes.io/name: buzz-sheet
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: buzz-sheet-mirror
minReplicas: 2
maxReplicas: 6
behavior:
scaleDown:
stabilizationWindowSeconds: 300
scaleUp:
stabilizationWindowSeconds: 30
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 75
+12
View File
@@ -0,0 +1,12 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: buzz-sheet-mirror
labels:
app.kubernetes.io/name: buzz-sheet
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: buzz-sheet-mirror
labels:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: buzz-sheet
app.kubernetes.io/component: public-mirror
ports:
- name: http
port: 3000
targetPort: http
protocol: TCP
+5
View File
@@ -0,0 +1,5 @@
export const basePath = process.env.NEXT_PUBLIC_BASE_PATH?.trim() ?? "";
export function withBasePath(path: string): string {
return `${basePath}${path}`;
}
+8 -6
View File
@@ -1,9 +1,11 @@
import { withBasePath } from "@/lib/base-path";
export function navigationIconPath(slug: string): string | null { export function navigationIconPath(slug: string): string | null {
if (slug === "overview") return "/icon/nav/Overview.webp"; if (slug === "overview") return withBasePath("/icon/nav/Overview.webp");
if (slug === "weapon") return "/icon/nav/Weapon.png"; if (slug === "weapon") return withBasePath("/icon/nav/Weapon.png");
if (slug === "artifact") return "/icon/nav/Artifact.webp"; if (slug === "artifact") return withBasePath("/icon/nav/Artifact.webp");
if (slug === "constellations") return "/icon/nav/Constellations.webp"; if (slug === "constellations") return withBasePath("/icon/nav/Constellations.webp");
if (slug === "team") return "/icon/nav/Team-calc.webp"; if (slug === "team") return withBasePath("/icon/nav/Team-calc.webp");
if (slug === "extra" || slug.startsWith("extra-")) return "/icon/nav/Extra.webp"; if (slug === "extra" || slug.startsWith("extra-")) return withBasePath("/icon/nav/Extra.webp");
return null; return null;
} }
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import { resolve } from "node:path"; import { resolve } from "node:path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
basePath: process.env.NEXT_PUBLIC_BASE_PATH?.trim() || undefined,
cacheComponents: true, cacheComponents: true,
cacheHandlers: { cacheHandlers: {
remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"), remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"),
+1 -1
View File
@@ -11,7 +11,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10", "logs": "kubectl logs --namespace buzz-sheet --selector 'app.kubernetes.io/name=buzz-sheet,app.kubernetes.io/component in (web,public-mirror,worker,discord-worker)' --all-containers=true --prefix=true --tail=100 --follow --max-log-requests=10",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "bun scripts/migrate.ts", "db:migrate": "bun scripts/migrate.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
export function proxy() {
if (process.env.PUBLIC_MIRROR === "true") {
return new NextResponse(null, { status: 404 });
}
return NextResponse.next();
}
export const config = {
matcher: [
"/admin/:path*",
"/register",
"/api/admin/:path*",
"/api/auth/:path*",
"/api/events/admin",
"/api/presence/admin",
],
};
+41 -1
View File
@@ -37,6 +37,9 @@ describe("production deployment contract", () => {
expect(service).toContain("targetPort: http"); expect(service).toContain("targetPort: http");
expect(ingress).toContain("ingressClassName: traefik"); expect(ingress).toContain("ingressClassName: traefik");
expect(ingress).toContain("host: guide.sudloh.com"); expect(ingress).toContain("host: guide.sudloh.com");
expect(ingress).toContain("host: buzz.sudloh.com");
expect(ingress).toContain("path: /guide");
expect(ingress).toContain("name: buzz-sheet-mirror");
expect(ingress).toContain( expect(ingress).toContain(
'traefik.ingress.kubernetes.io/read-timeout: "200"', 'traefik.ingress.kubernetes.io/read-timeout: "200"',
); );
@@ -44,6 +47,24 @@ describe("production deployment contract", () => {
expect(ingress).not.toContain("router.tls"); expect(ingress).not.toContain("router.tls");
}); });
it("runs the public mirror as an independently resilient workload", async () => {
const [deployment, service, hpa, pdb] = await Promise.all([
repositoryFile("k8s/base/mirror-deployment.yaml"),
repositoryFile("k8s/base/mirror-service.yaml"),
repositoryFile("k8s/base/mirror-hpa.yaml"),
repositoryFile("k8s/base/mirror-pdb.yaml"),
]);
expect(deployment).toContain("name: buzz-sheet-mirror");
expect(deployment).toContain("replicas: 2");
expect(deployment).toContain("path: /guide/api/health");
expect(deployment).toContain('value: "true"');
expect(service).toContain("app.kubernetes.io/component: public-mirror");
expect(hpa).toContain("name: buzz-sheet-mirror");
expect(hpa).toContain("maxReplicas: 6");
expect(pdb).toContain("minAvailable: 1");
});
it("keeps availability and scaling bounds explicit", async () => { it("keeps availability and scaling bounds explicit", async () => {
const [hpa, pdb] = await Promise.all([ const [hpa, pdb] = await Promise.all([
repositoryFile("k8s/base/hpa.yaml"), repositoryFile("k8s/base/hpa.yaml"),
@@ -63,12 +84,16 @@ describe("production deployment contract", () => {
"k8s/base/namespace.yaml", "k8s/base/namespace.yaml",
"k8s/base/configmap.yaml", "k8s/base/configmap.yaml",
"k8s/base/deployment.yaml", "k8s/base/deployment.yaml",
"k8s/base/mirror-deployment.yaml",
"k8s/base/worker-deployment.yaml", "k8s/base/worker-deployment.yaml",
"k8s/base/discord-worker-deployment.yaml", "k8s/base/discord-worker-deployment.yaml",
"k8s/base/service.yaml", "k8s/base/service.yaml",
"k8s/base/mirror-service.yaml",
"k8s/base/ingress.yaml", "k8s/base/ingress.yaml",
"k8s/base/hpa.yaml", "k8s/base/hpa.yaml",
"k8s/base/mirror-hpa.yaml",
"k8s/base/pdb.yaml", "k8s/base/pdb.yaml",
"k8s/base/mirror-pdb.yaml",
"k8s/base/ci-rbac.yaml", "k8s/base/ci-rbac.yaml",
"k8s/migration/job.yaml", "k8s/migration/job.yaml",
]; ];
@@ -110,6 +135,9 @@ describe("production deployment contract", () => {
expect(workflow).toContain("bun run lint"); expect(workflow).toContain("bun run lint");
expect(workflow).toContain("kubectl kustomize k8s/"); expect(workflow).toContain("kubectl kustomize k8s/");
expect(workflow).toContain("--target app"); expect(workflow).toContain("--target app");
expect(workflow).toContain("--build-arg NEXT_PUBLIC_BASE_PATH=/guide");
expect(workflow).toContain("--build-arg PUBLIC_MIRROR=true");
expect(workflow).toContain("mirror-${{ gitea.sha }}");
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"'); expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
expect(workflow).toContain( expect(workflow).toContain(
'--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"', '--build-arg NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID="$NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID"',
@@ -123,6 +151,7 @@ describe("production deployment contract", () => {
); );
expect(workflow).toContain("migrate-${{ gitea.sha }}"); expect(workflow).toContain("migrate-${{ gitea.sha }}");
expect(workflow).toContain("deployment/buzz-sheet-discord-worker"); expect(workflow).toContain("deployment/buzz-sheet-discord-worker");
expect(workflow).toContain("deployment/buzz-sheet-mirror");
const deleteMigrationJob = workflow.indexOf( const deleteMigrationJob = workflow.indexOf(
'delete job buzz-sheet-migrate --ignore-not-found', 'delete job buzz-sheet-migrate --ignore-not-found',
); );
@@ -145,11 +174,22 @@ describe("production deployment contract", () => {
]); ]);
expect(layout).toContain("<DeploymentUpdateNotifier"); expect(layout).toContain("<DeploymentUpdateNotifier");
expect(notifier).toContain('new EventSource("/api/active")'); expect(notifier).toContain('new EventSource(withBasePath("/api/active"))');
expect(notifier).toContain("window.location.reload()"); expect(notifier).toContain("window.location.reload()");
expect(notifier).toContain('label: "รีโหลด"'); expect(notifier).toContain('label: "รีโหลด"');
expect(route).toContain('channel.sendNamed("deployment", deploymentId)'); expect(route).toContain('channel.sendNamed("deployment", deploymentId)');
}); });
it("blocks private routes only in the public mirror build", async () => {
const proxy = await repositoryFile("proxy.ts");
expect(proxy).toContain('process.env.PUBLIC_MIRROR === "true"');
expect(proxy).toContain('"/admin/:path*"');
expect(proxy).toContain('"/api/admin/:path*"');
expect(proxy).toContain('"/api/auth/:path*"');
expect(proxy).toContain("status: 404");
expect(proxy).toContain("NextResponse.next()");
});
}); });
describe("environment template contract", () => { describe("environment template contract", () => {