feat : notify new update income
This commit is contained in:
@@ -50,6 +50,9 @@ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=replace-with-a-stable-32-byte-base64-key
|
|||||||
# Set to the immutable image revision for version-skew protection.
|
# Set to the immutable image revision for version-skew protection.
|
||||||
NEXT_DEPLOYMENT_ID=local-development
|
NEXT_DEPLOYMENT_ID=local-development
|
||||||
|
|
||||||
|
# Shared with CI to authenticate deployment lifecycle notifications.
|
||||||
|
DEPLOYMENT_WEBHOOK_SECRET=replace-with-a-strong-random-secret
|
||||||
|
|
||||||
# Readiness dependency timeout.
|
# Readiness dependency timeout.
|
||||||
HEALTHCHECK_TIMEOUT_MS=2500
|
HEALTHCHECK_TIMEOUT_MS=2500
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Announce incoming deployment
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_WEBHOOK_SECRET: ${{ secrets.DEPLOYMENT_WEBHOOK_SECRET }}
|
||||||
|
REVISION: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DEPLOYMENT_WEBHOOK_SECRET" ]; then
|
||||||
|
echo "DEPLOYMENT_WEBHOOK_SECRET is not configured; skipping notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
curl --fail-with-body --silent --show-error --retry 3 \
|
||||||
|
--header "Authorization: Bearer $DEPLOYMENT_WEBHOOK_SECRET" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{\"deploymentId\":\"$REVISION\",\"status\":\"deploying\"}" \
|
||||||
|
"$BASE_URL/api/deployments/status"
|
||||||
|
|
||||||
- name: Build and push application image
|
- name: Build and push application image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
@@ -172,3 +188,25 @@ jobs:
|
|||||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
||||||
deployment/buzz-sheet-discord-worker \
|
deployment/buzz-sheet-discord-worker \
|
||||||
--timeout=10m
|
--timeout=10m
|
||||||
|
|
||||||
|
- name: Publish deployment result
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
DEPLOYMENT_WEBHOOK_SECRET: ${{ secrets.DEPLOYMENT_WEBHOOK_SECRET }}
|
||||||
|
DEPLOYMENT_JOB_STATUS: ${{ job.status }}
|
||||||
|
REVISION: ${{ gitea.sha }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DEPLOYMENT_WEBHOOK_SECRET" ]; then
|
||||||
|
echo "DEPLOYMENT_WEBHOOK_SECRET is not configured; skipping notification"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
status=failed
|
||||||
|
if [ "$DEPLOYMENT_JOB_STATUS" = "success" ]; then
|
||||||
|
status=ready
|
||||||
|
fi
|
||||||
|
curl --fail-with-body --silent --show-error --retry 3 \
|
||||||
|
--header "Authorization: Bearer $DEPLOYMENT_WEBHOOK_SECRET" \
|
||||||
|
--header "Content-Type: application/json" \
|
||||||
|
--data "{\"deploymentId\":\"$REVISION\",\"status\":\"$status\"}" \
|
||||||
|
"$BASE_URL/api/deployments/status"
|
||||||
|
|||||||
+19
-7
@@ -3,7 +3,11 @@ import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
|
|||||||
import { connection } from "next/server";
|
import { connection } from "next/server";
|
||||||
|
|
||||||
import { normalizeDeploymentId } from "@/lib/deployment/version";
|
import { normalizeDeploymentId } from "@/lib/deployment/version";
|
||||||
import { createEventStream, eventStreamHeaders } from "@/lib/events/sse";
|
import {
|
||||||
|
DEPLOYMENT_EVENT_TOPIC,
|
||||||
|
getLatestDeploymentStatus,
|
||||||
|
} from "@/lib/deployment/repository";
|
||||||
|
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
await connection();
|
await connection();
|
||||||
@@ -11,12 +15,20 @@ export async function GET(request: Request) {
|
|||||||
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
||||||
const deploymentId =
|
const deploymentId =
|
||||||
normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development";
|
normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development";
|
||||||
const channel = createEventStream({ signal: request.signal });
|
return await createRedisNamedEventResponse(
|
||||||
channel.sendNamed("deployment", deploymentId);
|
DEPLOYMENT_EVENT_TOPIC,
|
||||||
|
"deployment-status",
|
||||||
return new Response(channel.stream, {
|
request.signal,
|
||||||
headers: eventStreamHeaders(),
|
async () => {
|
||||||
});
|
const latestStatus = await getLatestDeploymentStatus();
|
||||||
|
return [
|
||||||
|
{ eventName: "deployment", data: deploymentId },
|
||||||
|
...(latestStatus
|
||||||
|
? [{ eventName: "deployment-status", data: JSON.stringify(latestStatus) }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
return errorResponse(cause);
|
return errorResponse(cause);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const publishDeploymentStatus = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/deployment/repository", () => ({ publishDeploymentStatus }));
|
||||||
|
|
||||||
|
const { POST } = await import("./route");
|
||||||
|
const deploymentId = "a".repeat(40);
|
||||||
|
|
||||||
|
function request(body: unknown, authorization?: string) {
|
||||||
|
return new Request("https://guide.sudloh.com/api/deployments/status", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authorization ? { Authorization: authorization } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deployment status webhook", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.DEPLOYMENT_WEBHOOK_SECRET = "test-deployment-secret";
|
||||||
|
publishDeploymentStatus.mockReset();
|
||||||
|
publishDeploymentStatus.mockResolvedValue({
|
||||||
|
accepted: true,
|
||||||
|
event: {
|
||||||
|
deploymentId,
|
||||||
|
status: "deploying",
|
||||||
|
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing or incorrect credentials", async () => {
|
||||||
|
expect((await POST(request({ deploymentId, status: "deploying" }))).status).toBe(401);
|
||||||
|
expect((await POST(request(
|
||||||
|
{ deploymentId, status: "deploying" },
|
||||||
|
"Bearer incorrect",
|
||||||
|
))).status).toBe(401);
|
||||||
|
expect(publishDeploymentStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid lifecycle payloads", async () => {
|
||||||
|
const response = await POST(request(
|
||||||
|
{ deploymentId: "short", status: "unknown" },
|
||||||
|
"Bearer test-deployment-secret",
|
||||||
|
));
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(publishDeploymentStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes an authenticated lifecycle update", async () => {
|
||||||
|
const response = await POST(request(
|
||||||
|
{ deploymentId, status: "deploying" },
|
||||||
|
"Bearer test-deployment-secret",
|
||||||
|
));
|
||||||
|
expect(response.status).toBe(202);
|
||||||
|
expect(publishDeploymentStatus).toHaveBeenCalledWith(deploymentId, "deploying");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import { publishDeploymentStatus } from "@/lib/deployment/repository";
|
||||||
|
import {
|
||||||
|
isDeploymentId,
|
||||||
|
isDeploymentStatus,
|
||||||
|
} from "@/lib/deployment/status";
|
||||||
|
import {
|
||||||
|
errorResponse,
|
||||||
|
HttpError,
|
||||||
|
readJson,
|
||||||
|
} from "@/lib/security/http";
|
||||||
|
|
||||||
|
function authorized(request: Request): boolean {
|
||||||
|
const secret = process.env.DEPLOYMENT_WEBHOOK_SECRET;
|
||||||
|
const authorization = request.headers.get("authorization");
|
||||||
|
if (!secret) throw new HttpError(503, "deployment-webhook-not-configured");
|
||||||
|
if (!authorization?.startsWith("Bearer ")) return false;
|
||||||
|
const supplied = authorization.slice("Bearer ".length);
|
||||||
|
const expectedDigest = createHash("sha256").update(secret).digest();
|
||||||
|
const suppliedDigest = createHash("sha256").update(supplied).digest();
|
||||||
|
return timingSafeEqual(expectedDigest, suppliedDigest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
if (!authorized(request)) throw new HttpError(401, "unauthorized");
|
||||||
|
const body = await readJson(request, 1_024);
|
||||||
|
if (
|
||||||
|
typeof body !== "object" ||
|
||||||
|
body === null ||
|
||||||
|
!isDeploymentId((body as Record<string, unknown>).deploymentId) ||
|
||||||
|
!isDeploymentStatus((body as Record<string, unknown>).status)
|
||||||
|
) {
|
||||||
|
throw new HttpError(400, "invalid-deployment-status");
|
||||||
|
}
|
||||||
|
const deployment = body as {
|
||||||
|
deploymentId: string;
|
||||||
|
status: "deploying" | "ready" | "failed";
|
||||||
|
};
|
||||||
|
const result = await publishDeploymentStatus(
|
||||||
|
deployment.deploymentId,
|
||||||
|
deployment.status,
|
||||||
|
);
|
||||||
|
return Response.json(result, {
|
||||||
|
status: 202,
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
} catch (cause) {
|
||||||
|
return errorResponse(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,29 @@ import {
|
|||||||
hasDeploymentChanged,
|
hasDeploymentChanged,
|
||||||
normalizeDeploymentId,
|
normalizeDeploymentId,
|
||||||
} from "@/lib/deployment/version";
|
} from "@/lib/deployment/version";
|
||||||
|
import {
|
||||||
|
parseDeploymentStatusEvent,
|
||||||
|
type DeploymentStatus,
|
||||||
|
} from "@/lib/deployment/status";
|
||||||
|
|
||||||
const UPDATE_TOAST_ID = "deployment-update";
|
const UPDATE_TOAST_ID = "deployment-update";
|
||||||
|
const NOTICE_DURATION_MS = 10_000;
|
||||||
|
|
||||||
|
function noticeKey(deploymentId: string, status: DeploymentStatus): string {
|
||||||
|
return `deployment-notice:${deploymentId}:${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberNotice(seen: Set<string>, key: string): boolean {
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(key)) return false;
|
||||||
|
sessionStorage.setItem(key, "1");
|
||||||
|
} catch {
|
||||||
|
// In-memory deduplication still works when storage is unavailable.
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function DeploymentUpdateNotifier({
|
export function DeploymentUpdateNotifier({
|
||||||
deploymentId,
|
deploymentId,
|
||||||
@@ -17,23 +38,12 @@ export function DeploymentUpdateNotifier({
|
|||||||
}) {
|
}) {
|
||||||
const currentDeployment = useRef(normalizeDeploymentId(deploymentId));
|
const currentDeployment = useRef(normalizeDeploymentId(deploymentId));
|
||||||
const updateAvailable = useRef(false);
|
const updateAvailable = useRef(false);
|
||||||
|
const seenNotices = useRef(new Set<string>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const source = new EventSource("/api/active");
|
const source = new EventSource("/api/active");
|
||||||
const receiveDeployment = (event: Event) => {
|
const showReady = () => {
|
||||||
if (
|
if (updateAvailable.current) return;
|
||||||
updateAvailable.current ||
|
|
||||||
!(event instanceof MessageEvent) ||
|
|
||||||
typeof event.data !== "string"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDeployment = normalizeDeploymentId(event.data);
|
|
||||||
if (!hasDeploymentChanged(currentDeployment.current, nextDeployment)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAvailable.current = true;
|
updateAvailable.current = true;
|
||||||
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
|
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
|
||||||
id: UPDATE_TOAST_ID,
|
id: UPDATE_TOAST_ID,
|
||||||
@@ -45,10 +55,52 @@ export function DeploymentUpdateNotifier({
|
|||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const receiveDeployment = (event: Event) => {
|
||||||
|
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDeployment = normalizeDeploymentId(event.data);
|
||||||
|
if (hasDeploymentChanged(currentDeployment.current, nextDeployment)) {
|
||||||
|
showReady();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const receiveStatus = (event: Event) => {
|
||||||
|
if (!(event instanceof MessageEvent) || typeof event.data !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = parseDeploymentStatusEvent(event.data);
|
||||||
|
if (
|
||||||
|
!update ||
|
||||||
|
update.deploymentId === currentDeployment.current ||
|
||||||
|
updateAvailable.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = noticeKey(update.deploymentId, update.status);
|
||||||
|
if (!rememberNotice(seenNotices.current, key)) return;
|
||||||
|
|
||||||
|
if (update.status === "ready") {
|
||||||
|
showReady();
|
||||||
|
} else if (update.status === "deploying") {
|
||||||
|
toast("กำลังอัปเดตเว็บไซต์", {
|
||||||
|
id: UPDATE_TOAST_ID,
|
||||||
|
description: "เวอร์ชันใหม่จะพร้อมใช้งานในอีกไม่กี่นาที",
|
||||||
|
duration: NOTICE_DURATION_MS,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast("การอัปเดตล่าช้า", {
|
||||||
|
id: UPDATE_TOAST_ID,
|
||||||
|
description: "เวอร์ชันปัจจุบันยังใช้งานได้ตามปกติ",
|
||||||
|
duration: NOTICE_DURATION_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
source.addEventListener("deployment", receiveDeployment);
|
source.addEventListener("deployment", receiveDeployment);
|
||||||
|
source.addEventListener("deployment-status", receiveStatus);
|
||||||
return () => {
|
return () => {
|
||||||
source.removeEventListener("deployment", receiveDeployment);
|
source.removeEventListener("deployment", receiveDeployment);
|
||||||
|
source.removeEventListener("deployment-status", receiveStatus);
|
||||||
source.close();
|
source.close();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ kubectl get secret buzz-sheet-env \
|
|||||||
--output='go-template={{range $key, $value := .data}}{{$key}}{{"\n"}}{{end}}'
|
--output='go-template={{range $key, $value := .data}}{{$key}}{{"\n"}}{{end}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Add `DEPLOYMENT_WEBHOOK_SECRET` to the Gitea Actions repository secrets with
|
||||||
|
the same value stored in `.env`. CI uses it to announce deployment lifecycle
|
||||||
|
updates to the running site.
|
||||||
|
|
||||||
## 3. Restart the application
|
## 3. Restart the application
|
||||||
|
|
||||||
Environment variables sourced from a Secret are read when a pod starts.
|
Environment variables sourced from a Secret are read when a pod starts.
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: buzz-sheet-config
|
name: buzz-sheet-config
|
||||||
key: NEXT_DEPLOYMENT_ID
|
key: NEXT_DEPLOYMENT_ID
|
||||||
|
- name: DEPLOYMENT_WEBHOOK_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: buzz-sheet-env
|
||||||
|
key: DEPLOYMENT_WEBHOOK_SECRET
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DeploymentStatus,
|
||||||
|
DeploymentStatusEvent,
|
||||||
|
} from "./status";
|
||||||
|
import { parseDeploymentStatusEvent } from "./status";
|
||||||
|
import {
|
||||||
|
getRedisClient,
|
||||||
|
redisEventChannel,
|
||||||
|
redisEventPrefix,
|
||||||
|
} from "@/lib/redis/client";
|
||||||
|
|
||||||
|
export const DEPLOYMENT_EVENT_TOPIC = "deployments";
|
||||||
|
|
||||||
|
const statusKey = () => `${redisEventPrefix()}:deployments:latest`;
|
||||||
|
const STATUS_TTL_SECONDS: Record<DeploymentStatus, number> = {
|
||||||
|
deploying: 60 * 60,
|
||||||
|
ready: 10 * 60,
|
||||||
|
failed: 10 * 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishScript = `
|
||||||
|
if ARGV[2] ~= "deploying" then
|
||||||
|
local current = redis.call("GET", KEYS[1])
|
||||||
|
if not current then return 0 end
|
||||||
|
local ok, decoded = pcall(cjson.decode, current)
|
||||||
|
if not ok or decoded.deploymentId ~= ARGV[3] then return 0 end
|
||||||
|
end
|
||||||
|
redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[4])
|
||||||
|
redis.call("PUBLISH", KEYS[2], ARGV[1])
|
||||||
|
return 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function getLatestDeploymentStatus(): Promise<DeploymentStatusEvent | null> {
|
||||||
|
const redis = await getRedisClient();
|
||||||
|
const value = await redis.get(statusKey());
|
||||||
|
return value ? parseDeploymentStatusEvent(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishDeploymentStatus(
|
||||||
|
deploymentId: string,
|
||||||
|
status: DeploymentStatus,
|
||||||
|
): Promise<{ accepted: boolean; event: DeploymentStatusEvent }> {
|
||||||
|
const event: DeploymentStatusEvent = {
|
||||||
|
deploymentId,
|
||||||
|
status,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
const redis = await getRedisClient();
|
||||||
|
const accepted = await redis.eval(
|
||||||
|
publishScript,
|
||||||
|
2,
|
||||||
|
statusKey(),
|
||||||
|
redisEventChannel(DEPLOYMENT_EVENT_TOPIC),
|
||||||
|
payload,
|
||||||
|
status,
|
||||||
|
deploymentId,
|
||||||
|
STATUS_TTL_SECONDS[status],
|
||||||
|
);
|
||||||
|
return { accepted: accepted === 1, event };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isDeploymentId,
|
||||||
|
isDeploymentStatus,
|
||||||
|
parseDeploymentStatusEvent,
|
||||||
|
} from "./status";
|
||||||
|
|
||||||
|
const deploymentId = "a".repeat(40);
|
||||||
|
|
||||||
|
describe("deployment lifecycle status", () => {
|
||||||
|
it("accepts supported statuses and full commit identifiers", () => {
|
||||||
|
expect(isDeploymentId(deploymentId)).toBe(true);
|
||||||
|
expect(isDeploymentId("abc123")).toBe(false);
|
||||||
|
expect(isDeploymentStatus("deploying")).toBe(true);
|
||||||
|
expect(isDeploymentStatus("ready")).toBe(true);
|
||||||
|
expect(isDeploymentStatus("failed")).toBe(true);
|
||||||
|
expect(isDeploymentStatus("unknown")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a valid lifecycle event", () => {
|
||||||
|
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||||
|
deploymentId,
|
||||||
|
status: "deploying",
|
||||||
|
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||||
|
}))).toEqual({
|
||||||
|
deploymentId,
|
||||||
|
status: "deploying",
|
||||||
|
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or incomplete events", () => {
|
||||||
|
expect(parseDeploymentStatusEvent("not-json")).toBeNull();
|
||||||
|
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||||
|
deploymentId,
|
||||||
|
status: "unknown",
|
||||||
|
updatedAt: "2026-09-26T12:00:00.000Z",
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(parseDeploymentStatusEvent(JSON.stringify({
|
||||||
|
deploymentId,
|
||||||
|
status: "ready",
|
||||||
|
updatedAt: "not-a-date",
|
||||||
|
}))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const DEPLOYMENT_STATUSES = ["deploying", "ready", "failed"] as const;
|
||||||
|
|
||||||
|
export type DeploymentStatus = (typeof DEPLOYMENT_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface DeploymentStatusEvent {
|
||||||
|
deploymentId: string;
|
||||||
|
status: DeploymentStatus;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEPLOYMENT_ID_PATTERN = /^[a-f0-9]{40}$/u;
|
||||||
|
|
||||||
|
export function isDeploymentId(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && DEPLOYMENT_ID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDeploymentStatus(value: unknown): value is DeploymentStatus {
|
||||||
|
return DEPLOYMENT_STATUSES.some((status) => status === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDeploymentStatusEvent(
|
||||||
|
value: string,
|
||||||
|
): DeploymentStatusEvent | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
!isDeploymentId(parsed.deploymentId) ||
|
||||||
|
!isDeploymentStatus(parsed.status) ||
|
||||||
|
typeof parsed.updatedAt !== "string" ||
|
||||||
|
!Number.isFinite(Date.parse(parsed.updatedAt))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
deploymentId: parsed.deploymentId,
|
||||||
|
status: parsed.status,
|
||||||
|
updatedAt: parsed.updatedAt,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-11
@@ -3,8 +3,12 @@ import { createEventStream, eventStreamHeaders, type EventStreamChannel } from "
|
|||||||
import { connectRedisClient, createRedisClient, redisEventChannel } from "@/lib/redis/client";
|
import { connectRedisClient, createRedisClient, redisEventChannel } from "@/lib/redis/client";
|
||||||
|
|
||||||
type Subscriber = { client: ReturnType<typeof createRedisClient>; ready: Promise<void> };
|
type Subscriber = { client: ReturnType<typeof createRedisClient>; ready: Promise<void> };
|
||||||
|
type StreamListener = {
|
||||||
|
stream: EventStreamChannel;
|
||||||
|
receive(value: string): void;
|
||||||
|
};
|
||||||
let subscriber: Subscriber | undefined;
|
let subscriber: Subscriber | undefined;
|
||||||
const listeners = new Map<string, Set<EventStreamChannel>>();
|
const listeners = new Map<string, Set<StreamListener>>();
|
||||||
// Serialize subscribe/unsubscribe so a last-reader disconnect cannot race a join.
|
// Serialize subscribe/unsubscribe so a last-reader disconnect cannot race a join.
|
||||||
let operations = Promise.resolve();
|
let operations = Promise.resolve();
|
||||||
function ordered<T>(task: () => Promise<T>): Promise<T> {
|
function ordered<T>(task: () => Promise<T>): Promise<T> {
|
||||||
@@ -19,15 +23,15 @@ function getSubscriber(): Subscriber {
|
|||||||
const state: Subscriber = { client, ready: Promise.resolve() };
|
const state: Subscriber = { client, ready: Promise.resolve() };
|
||||||
subscriber = state;
|
subscriber = state;
|
||||||
client.on("message", (channel, value) => {
|
client.on("message", (channel, value) => {
|
||||||
const event = parseInvalidationEvent(value);
|
for (const listener of listeners.get(channel) ?? []) listener.receive(value);
|
||||||
if (!event || redisEventChannel(expectedTopic(event)) !== channel) return;
|
|
||||||
for (const stream of listeners.get(channel) ?? []) stream.send(event);
|
|
||||||
});
|
});
|
||||||
client.on("close", () => {
|
client.on("close", () => {
|
||||||
if (subscriber !== state) return;
|
if (subscriber !== state) return;
|
||||||
subscriber = undefined;
|
subscriber = undefined;
|
||||||
// Force EventSource reconnect/refetch after a gap in the Pub/Sub delivery.
|
// Force EventSource reconnect/refetch after a gap in the Pub/Sub delivery.
|
||||||
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
|
for (const streams of listeners.values()) {
|
||||||
|
for (const listener of [...streams]) listener.stream.close();
|
||||||
|
}
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
});
|
});
|
||||||
@@ -39,7 +43,18 @@ function getSubscriber(): Subscriber {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRedisEventResponse(topic: string, signal: AbortSignal): Promise<Response> {
|
interface RedisStreamOptions {
|
||||||
|
initialEvents?:
|
||||||
|
| ReadonlyArray<{ eventName: string; data: string }>
|
||||||
|
| (() => Promise<ReadonlyArray<{ eventName: string; data: string }>>);
|
||||||
|
receive(stream: EventStreamChannel, value: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRedisStreamResponse(
|
||||||
|
topic: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
options: RedisStreamOptions,
|
||||||
|
): Promise<Response> {
|
||||||
const channel = redisEventChannel(topic);
|
const channel = redisEventChannel(topic);
|
||||||
const state = getSubscriber();
|
const state = getSubscriber();
|
||||||
await state.ready;
|
await state.ready;
|
||||||
@@ -50,9 +65,9 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
|||||||
await state.client.subscribe(channel);
|
await state.client.subscribe(channel);
|
||||||
listeners.set(channel, new Set());
|
listeners.set(channel, new Set());
|
||||||
}
|
}
|
||||||
let eventStream: EventStreamChannel | undefined;
|
let listener: StreamListener | undefined;
|
||||||
const release = () => {
|
const release = () => {
|
||||||
if (eventStream) listeners.get(channel)?.delete(eventStream);
|
if (listener) listeners.get(channel)?.delete(listener);
|
||||||
void ordered(async () => {
|
void ordered(async () => {
|
||||||
if (subscriber === state && listeners.get(channel)?.size === 0) {
|
if (subscriber === state && listeners.get(channel)?.size === 0) {
|
||||||
listeners.delete(channel);
|
listeners.delete(channel);
|
||||||
@@ -62,10 +77,21 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
|||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
eventStream = createEventStream({ signal, onClose: release });
|
const eventStream = createEventStream({ signal, onClose: release });
|
||||||
listeners.get(channel)?.add(eventStream);
|
listener = {
|
||||||
|
stream: eventStream,
|
||||||
|
receive: (value) => options.receive(eventStream, value),
|
||||||
|
};
|
||||||
|
listeners.get(channel)?.add(listener);
|
||||||
|
const initialEvents = typeof options.initialEvents === "function"
|
||||||
|
? await options.initialEvents()
|
||||||
|
: options.initialEvents ?? [];
|
||||||
|
for (const event of initialEvents) {
|
||||||
|
eventStream.sendNamed(event.eventName, event.data);
|
||||||
|
}
|
||||||
return eventStream;
|
return eventStream;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
listener?.stream.close();
|
||||||
release();
|
release();
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
@@ -73,10 +99,38 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
|||||||
return new Response(stream.stream, { headers: eventStreamHeaders() });
|
return new Response(stream.stream, { headers: eventStreamHeaders() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRedisEventResponse(
|
||||||
|
topic: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Response> {
|
||||||
|
return createRedisStreamResponse(topic, signal, {
|
||||||
|
receive(stream, value) {
|
||||||
|
const event = parseInvalidationEvent(value);
|
||||||
|
if (event && expectedTopic(event) === topic) stream.send(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRedisNamedEventResponse(
|
||||||
|
topic: string,
|
||||||
|
eventName: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
initialEvents: RedisStreamOptions["initialEvents"] = [],
|
||||||
|
): Promise<Response> {
|
||||||
|
return createRedisStreamResponse(topic, signal, {
|
||||||
|
initialEvents,
|
||||||
|
receive(stream, value) {
|
||||||
|
stream.sendNamed(eventName, value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function closeRedisEventStreams() {
|
export function closeRedisEventStreams() {
|
||||||
const state = subscriber;
|
const state = subscriber;
|
||||||
subscriber = undefined;
|
subscriber = undefined;
|
||||||
for (const streams of listeners.values()) for (const stream of [...streams]) stream.close();
|
for (const streams of listeners.values()) {
|
||||||
|
for (const listener of [...streams]) listener.stream.close();
|
||||||
|
}
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
state?.client.disconnect();
|
state?.client.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,25 @@ describe("production deployment contract", () => {
|
|||||||
expect(notifier).toContain('new EventSource("/api/active")');
|
expect(notifier).toContain('new EventSource("/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(notifier).toContain('source.addEventListener("deployment-status"');
|
||||||
|
expect(route).toContain('"deployment-status"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces deployment lifecycle results without blocking releases", async () => {
|
||||||
|
const [workflow, deployment] = await Promise.all([
|
||||||
|
repositoryFile(".gitea/workflows/ci.yml"),
|
||||||
|
repositoryFile("k8s/base/deployment.yaml"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(workflow).toContain("Announce incoming deployment");
|
||||||
|
expect(workflow).toContain("Publish deployment result");
|
||||||
|
expect(workflow).toContain("if: always()");
|
||||||
|
expect(workflow).toContain("continue-on-error: true");
|
||||||
|
expect(workflow).toContain('status\\":\\"deploying');
|
||||||
|
expect(workflow).toContain("DEPLOYMENT_JOB_STATUS");
|
||||||
|
expect(workflow).toContain("/api/deployments/status");
|
||||||
|
expect(deployment).toContain("DEPLOYMENT_WEBHOOK_SECRET");
|
||||||
|
expect(deployment).toContain("optional: true");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +192,7 @@ describe("environment template contract", () => {
|
|||||||
"S3_SECRET_ACCESS_KEY",
|
"S3_SECRET_ACCESS_KEY",
|
||||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||||
"NEXT_DEPLOYMENT_ID",
|
"NEXT_DEPLOYMENT_ID",
|
||||||
|
"DEPLOYMENT_WEBHOOK_SECRET",
|
||||||
"DISCORD_BOT_TOKEN",
|
"DISCORD_BOT_TOKEN",
|
||||||
"DISCORD_CHANNEL_ID",
|
"DISCORD_CHANNEL_ID",
|
||||||
"DISCORD_LOG_CHANNEL_ID",
|
"DISCORD_LOG_CHANNEL_ID",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|||||||
vi.mock("server-only", () => ({}));
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
const { closeRedisEventStreams, createRedisEventResponse } = await import("@/lib/events/redis-stream");
|
const { closeRedisEventStreams, createRedisEventResponse } = await import("@/lib/events/redis-stream");
|
||||||
|
const { getLatestDeploymentStatus, publishDeploymentStatus } = await import("@/lib/deployment/repository");
|
||||||
const { updateVisitorPresence } = await import("@/lib/presence");
|
const { updateVisitorPresence } = await import("@/lib/presence");
|
||||||
const { closeRedisClient, redisEventChannel } = await import("@/lib/redis/client");
|
const { closeRedisClient, redisEventChannel } = await import("@/lib/redis/client");
|
||||||
const { consumeRateLimit } = await import("@/lib/security/rate-limit");
|
const { consumeRateLimit } = await import("@/lib/security/rate-limit");
|
||||||
@@ -102,4 +103,17 @@ describeWithRedis("shared Redis security paths", () => {
|
|||||||
second.abort();
|
second.abort();
|
||||||
await Promise.all([readerA.cancel(), readerB.cancel()]);
|
await Promise.all([readerA.cancel(), readerB.cancel()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let stale deployment results replace a newer announcement", async () => {
|
||||||
|
const firstId = "a".repeat(40);
|
||||||
|
const secondId = "b".repeat(40);
|
||||||
|
expect((await publishDeploymentStatus(firstId, "deploying")).accepted).toBe(true);
|
||||||
|
expect((await publishDeploymentStatus(secondId, "deploying")).accepted).toBe(true);
|
||||||
|
expect((await publishDeploymentStatus(firstId, "ready")).accepted).toBe(false);
|
||||||
|
expect(await getLatestDeploymentStatus()).toMatchObject({
|
||||||
|
deploymentId: secondId,
|
||||||
|
status: "deploying",
|
||||||
|
});
|
||||||
|
expect((await publishDeploymentStatus(secondId, "failed")).accepted).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user