feat : overwite buzz.sudloh.com/guide
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] },
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
}
|
||||
|
||||
@@ -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<"/">) {
|
||||
|
||||
@@ -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,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
@@ -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}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user