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/
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:
name: Build immutable images and deploy
needs: verify
if: >-
gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main' &&
@@ -40,6 +72,20 @@ jobs:
.
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
run: |
docker build \
@@ -138,6 +184,9 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" set image \
deployment/buzz-sheet \
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 \
deployment/buzz-sheet-worker \
worker="$REGISTRY_IMAGE:$REVISION"
@@ -147,6 +196,9 @@ jobs:
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet \
--timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-mirror \
--timeout=10m
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
deployment/buzz-sheet-worker \
--timeout=10m
+4
View File
@@ -23,10 +23,14 @@ FROM node:22-bookworm-slim AS next-builder
WORKDIR /app
ARG NEXT_DEPLOYMENT_ID=container-build
ARG BASE_URL
ARG NEXT_PUBLIC_BASE_PATH
ARG NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID
ARG PUBLIC_MIRROR=false
ENV BASE_URL=${BASE_URL}
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 PUBLIC_MIRROR=${PUBLIC_MIRROR}
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
+13 -2
View File
@@ -3,7 +3,8 @@
Buzz Sheet is a Thai-first guide CMS for turning structured character, build,
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
not affiliated with or endorsed by HoYoverse.
@@ -181,7 +182,8 @@ Kustomize resources live in `k8s/` and define:
- Namespace `buzz-sheet`.
- 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.
- Startup, liveness, and dependency-aware readiness probes.
- 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
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.
For initial bootstrap, the platform operator applies `k8s/base` through an
environment overlay that supplies an existing immutable image revision. The
aggregate `k8s/` kustomization includes the migration resource for rendering and
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:
```bash
+3 -1
View File
@@ -4,18 +4,20 @@ import { notFound } from "next/navigation";
import { GoogleAdsense } from "@/components/public/google-adsense";
import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
import { siteUrl } from "@/lib/site-url";
type Params = Promise<{ character: string; page: string }>;
export const instant = false;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { character } = await params;
const { character, page } = await params;
const guide = await getCachedPublicGuide(character);
return guide
? {
title: `${guide.name}`,
description: guide.overview,
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}/${encodeURIComponent(page)}` },
openGraph: { 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 { StructuredGuideShell } from "@/components/public/structured-guide";
import { getCachedPublicGuide } from "@/lib/cache/public";
import { siteUrl } from "@/lib/site-url";
type Params = Promise<{ character: string }>;
@@ -19,6 +20,7 @@ export async function generateMetadata({
? {
title: `${guide.name}`,
description: guide.overview,
alternates: { canonical: `${siteUrl}/${encodeURIComponent(guide.slug)}` },
openGraph: { images: [guide.coverUrl] },
twitter: { images: [guide.coverUrl] },
}
+3
View File
@@ -27,6 +27,9 @@ export const metadata: Metadata = {
},
description: "ไกด์ตัวละครจากช่องเกนชินไม่ใช่เกมมือถือ",
keywords: ["เกนชิน", "เกนชินไม่ใช่เกมมือถือ", "Buzz", "ไกด์", "สุดหล่อ"],
robots: process.env.PUBLIC_MIRROR === "true"
? { index: false, follow: false }
: undefined,
};
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 { SiteHeader } from "@/components/public/site-header";
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() {
await connection();
+3
View File
@@ -3,6 +3,9 @@ import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/site-url";
export default function robots(): MetadataRoute.Robots {
if (process.env.PUBLIC_MIRROR === "true") {
return { rules: { userAgent: "*", disallow: "/" } };
}
return {
rules: {
userAgent: "*",
+5 -2
View File
@@ -10,6 +10,8 @@ import {
} from "@/components/ui/avatar";
import { SiteFooter } from "@/components/public/site-footer";
import { Button } from "@/components/ui/button";
import { withBasePath } from "@/lib/base-path";
import { siteUrl } from "@/lib/site-url";
import {
Card,
CardContent,
@@ -57,6 +59,7 @@ const specialThanks: ThanksProfile[] = [
export const metadata: Metadata = {
title: "Special Thanks",
description: "ขอบคุณทุกคนที่มีส่วนช่วยให้ Buzz Guide เกิดขึ้นได้",
alternates: { canonical: `${siteUrl}/thanks` },
};
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"
>
<Image
src="/icon.png"
src={withBasePath("/icon.png")}
alt=""
width={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">
<CardHeader className="grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1 py-4 text-left sm:flex-1">
<Image
src="/thanks/astral.png"
src={withBasePath("/thanks/astral.png")}
alt="Astral Development
"
width={128}
+2 -1
View File
@@ -7,6 +7,7 @@ import {
hasDeploymentChanged,
normalizeDeploymentId,
} from "@/lib/deployment/version";
import { withBasePath } from "@/lib/base-path";
const UPDATE_TOAST_ID = "deployment-update";
@@ -19,7 +20,7 @@ export function DeploymentUpdateNotifier({
const updateAvailable = useRef(false);
useEffect(() => {
const source = new EventSource("/api/active");
const source = new EventSource(withBasePath("/api/active"));
const receiveDeployment = (event: Event) => {
if (
updateAvailable.current ||
+2 -1
View File
@@ -5,13 +5,14 @@ import { useEffect } from "react";
import { parseInvalidationEvent } from "@/lib/events/invalidation";
import { createRefreshScheduler } from "@/lib/events/refresh";
import { withBasePath } from "@/lib/base-path";
export function LiveRefresh({ endpoint }: { endpoint: string }) {
const router = useRouter();
useEffect(() => {
const scheduler = createRefreshScheduler(() => router.refresh());
const source = new EventSource(endpoint);
const source = new EventSource(withBasePath(endpoint));
const connected = () => scheduler.connected();
const notified = (message: Event) => {
if (!(message instanceof MessageEvent) || typeof message.data !== "string") {
+2 -1
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
import { GuideExportBranding } from "@/components/public/guide-export-branding";
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">
<Image
src="/icon/nav/Constellations.webp"
src={withBasePath("/icon/nav/Constellations.webp")}
alt=""
width={112}
height={112}
+2 -1
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
export function GuideExportBranding({
backgroundUrl,
@@ -21,7 +22,7 @@ export function GuideExportBranding({
/>
<div className="absolute inset-0 bg-[#050817]/65" />
<Image
src="/icon/Watermark.png"
src={withBasePath("/icon/Watermark.png")}
alt=""
width={constellation ? 280 : 180}
height={constellation ? 158 : 101}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import { withBasePath } from "@/lib/base-path";
import Link from "next/link";
import { useRef, type PointerEvent } from "react";
@@ -170,7 +171,7 @@ export function MaterialPoster({
</div>
) : null}
<Image
src="/img/mat/fade.png"
src={withBasePath("/img/mat/fade.png")}
alt=""
fill
sizes={`${POSTER_EXPORT_WIDTH}px`}
+3 -1
View File
@@ -5,8 +5,10 @@ import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import { VisitorPresence } from "@/components/public/visitor-presence";
import { getAdminSession } from "@/lib/auth/server";
import { withBasePath } from "@/lib/base-path";
async function AdminEditLink({ editHref }: { editHref: string }) {
if (process.env.PUBLIC_MIRROR === "true") return null;
const isAdmin = Boolean(await getAdminSession());
return isAdmin ? (
@@ -29,7 +31,7 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
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"
>
<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>
</Link>
<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 { artifactTierCount } from "@/lib/guides/artifacts";
import { navigationIconPath } from "@/lib/navigation";
import { withBasePath } from "@/lib/base-path";
import {
normalizeTargetStat,
recommendedStatElement,
@@ -419,7 +420,7 @@ function WeaponSectionContent({
</p>
<div className="flex items-center justify-center gap-3">
<Image
src="/icon/nav/Weapon.png"
src={withBasePath("/icon/nav/Weapon.png")}
alt=""
width={56}
height={56}
@@ -656,21 +657,21 @@ function ArtifactSectionContent({
</CardHeader>
<CardContent className="grid grid-cols-3 p-0">
<ArtifactStat
iconSrc="/icon/Icon_Sands_of_Eon.webp"
iconSrc={withBasePath("/icon/Icon_Sands_of_Eon.webp")}
label={exportLabel(exporting, "นาฬิกา", "Sands")}
values={condition.sands}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
iconSrc={withBasePath("/icon/Icon_Goblet_of_Eonothem.webp")}
label={exportLabel(exporting, "ถ้วย", "Goblet")}
values={condition.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
iconSrc={withBasePath("/icon/Icon_Circlet_of_Logos.webp")}
label={exportLabel(exporting, "หัว", "Circlet")}
values={condition.circlet}
exporting={exporting}
@@ -721,7 +722,7 @@ function ArtifactSectionContent({
</p>
<div className="flex items-center justify-center gap-3">
<Image
src="/icon/nav/Artifact.webp"
src={withBasePath("/icon/nav/Artifact.webp")}
alt=""
width={56}
height={56}
@@ -1155,7 +1156,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
</p>
<div className="flex items-center justify-center gap-3">
<Image
src="/icon/nav/Constellations.webp"
src={withBasePath("/icon/nav/Constellations.webp")}
alt=""
width={56}
height={56}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useEffectEvent, useState } from "react";
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
import { withBasePath } from "@/lib/base-path";
type VisitorCounts = {
global: number;
@@ -51,7 +52,7 @@ function useHeartbeat<T>({
const [value, setValue] = useState<T | null>(null);
const send = useEffectEvent(async (active = true) => {
try {
const response = await fetch(endpoint, {
const response = await fetch(withBasePath(endpoint), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+6
View File
@@ -55,6 +55,8 @@ Restart all Buzz Sheet workloads after updating the Secret:
```bash
kubectl rollout restart deployment/buzz-sheet \
--namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-mirror \
--namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-worker \
--namespace buzz-sheet
kubectl rollout restart deployment/buzz-sheet-discord-worker \
@@ -67,6 +69,9 @@ Wait for every rollout to finish:
kubectl rollout status deployment/buzz-sheet \
--namespace buzz-sheet \
--timeout=10m
kubectl rollout status deployment/buzz-sheet-mirror \
--namespace buzz-sheet \
--timeout=10m
kubectl rollout status deployment/buzz-sheet-worker \
--namespace buzz-sheet \
--timeout=10m
@@ -79,6 +84,7 @@ kubectl rollout status deployment/buzz-sheet-discord-worker \
```bash
curl -fsS 'https://guide.sudloh.com/api/health?ready=1'
curl -fsS 'https://buzz.sudloh.com/guide/api/health?ready=1'
bun logs
```
+1 -1
View File
@@ -26,7 +26,7 @@ rules:
verbs: ["get"]
- apiGroups: ["apps"]
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"]
- apiGroups: ["apps"]
resources: ["replicasets"]
+10
View File
@@ -19,3 +19,13 @@ spec:
name: buzz-sheet
port:
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
- configmap.yaml
- deployment.yaml
- mirror-deployment.yaml
- worker-deployment.yaml
- discord-worker-deployment.yaml
- service.yaml
- mirror-service.yaml
- ingress.yaml
- hpa.yaml
- mirror-hpa.yaml
- pdb.yaml
- mirror-pdb.yaml
- ci-rbac.yaml
images:
- name: buzz-sheet-app
newName: registry.neko-piranha.ts.net/astral/buzz-sheet
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 {
if (slug === "overview") return "/icon/nav/Overview.webp";
if (slug === "weapon") return "/icon/nav/Weapon.png";
if (slug === "artifact") return "/icon/nav/Artifact.webp";
if (slug === "constellations") return "/icon/nav/Constellations.webp";
if (slug === "team") return "/icon/nav/Team-calc.webp";
if (slug === "extra" || slug.startsWith("extra-")) return "/icon/nav/Extra.webp";
if (slug === "overview") return withBasePath("/icon/nav/Overview.webp");
if (slug === "weapon") return withBasePath("/icon/nav/Weapon.png");
if (slug === "artifact") return withBasePath("/icon/nav/Artifact.webp");
if (slug === "constellations") return withBasePath("/icon/nav/Constellations.webp");
if (slug === "team") return withBasePath("/icon/nav/Team-calc.webp");
if (slug === "extra" || slug.startsWith("extra-")) return withBasePath("/icon/nav/Extra.webp");
return null;
}
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import { resolve } from "node:path";
const nextConfig: NextConfig = {
basePath: process.env.NEXT_PUBLIC_BASE_PATH?.trim() || undefined,
cacheComponents: true,
cacheHandlers: {
remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"),
+1 -1
View File
@@ -11,7 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"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:migrate": "bun scripts/migrate.ts",
"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(ingress).toContain("ingressClassName: traefik");
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(
'traefik.ingress.kubernetes.io/read-timeout: "200"',
);
@@ -44,6 +47,24 @@ describe("production deployment contract", () => {
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 () => {
const [hpa, pdb] = await Promise.all([
repositoryFile("k8s/base/hpa.yaml"),
@@ -63,12 +84,16 @@ describe("production deployment contract", () => {
"k8s/base/namespace.yaml",
"k8s/base/configmap.yaml",
"k8s/base/deployment.yaml",
"k8s/base/mirror-deployment.yaml",
"k8s/base/worker-deployment.yaml",
"k8s/base/discord-worker-deployment.yaml",
"k8s/base/service.yaml",
"k8s/base/mirror-service.yaml",
"k8s/base/ingress.yaml",
"k8s/base/hpa.yaml",
"k8s/base/mirror-hpa.yaml",
"k8s/base/pdb.yaml",
"k8s/base/mirror-pdb.yaml",
"k8s/base/ci-rbac.yaml",
"k8s/migration/job.yaml",
];
@@ -110,6 +135,9 @@ describe("production deployment contract", () => {
expect(workflow).toContain("bun run lint");
expect(workflow).toContain("kubectl kustomize k8s/");
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 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("deployment/buzz-sheet-discord-worker");
expect(workflow).toContain("deployment/buzz-sheet-mirror");
const deleteMigrationJob = workflow.indexOf(
'delete job buzz-sheet-migrate --ignore-not-found',
);
@@ -145,11 +174,22 @@ describe("production deployment contract", () => {
]);
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('label: "รีโหลด"');
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", () => {