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.
|
||||
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.
|
||||
HEALTHCHECK_TIMEOUT_MS=2500
|
||||
|
||||
|
||||
@@ -47,6 +47,22 @@ jobs:
|
||||
- name: Check out repository
|
||||
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
|
||||
run: |
|
||||
docker build \
|
||||
@@ -172,3 +188,25 @@ jobs:
|
||||
kubectl --kubeconfig "$KUBECONFIG" --namespace "$DEPLOY_NAMESPACE" rollout status \
|
||||
deployment/buzz-sheet-discord-worker \
|
||||
--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 { 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) {
|
||||
await connection();
|
||||
@@ -11,12 +15,20 @@ export async function GET(request: Request) {
|
||||
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
|
||||
const deploymentId =
|
||||
normalizeDeploymentId(process.env.NEXT_DEPLOYMENT_ID) ?? "development";
|
||||
const channel = createEventStream({ signal: request.signal });
|
||||
channel.sendNamed("deployment", deploymentId);
|
||||
|
||||
return new Response(channel.stream, {
|
||||
headers: eventStreamHeaders(),
|
||||
});
|
||||
return await createRedisNamedEventResponse(
|
||||
DEPLOYMENT_EVENT_TOPIC,
|
||||
"deployment-status",
|
||||
request.signal,
|
||||
async () => {
|
||||
const latestStatus = await getLatestDeploymentStatus();
|
||||
return [
|
||||
{ eventName: "deployment", data: deploymentId },
|
||||
...(latestStatus
|
||||
? [{ eventName: "deployment-status", data: JSON.stringify(latestStatus) }]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
);
|
||||
} catch (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,
|
||||
normalizeDeploymentId,
|
||||
} from "@/lib/deployment/version";
|
||||
import {
|
||||
parseDeploymentStatusEvent,
|
||||
type DeploymentStatus,
|
||||
} from "@/lib/deployment/status";
|
||||
|
||||
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({
|
||||
deploymentId,
|
||||
@@ -17,23 +38,12 @@ export function DeploymentUpdateNotifier({
|
||||
}) {
|
||||
const currentDeployment = useRef(normalizeDeploymentId(deploymentId));
|
||||
const updateAvailable = useRef(false);
|
||||
const seenNotices = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource("/api/active");
|
||||
const receiveDeployment = (event: Event) => {
|
||||
if (
|
||||
updateAvailable.current ||
|
||||
!(event instanceof MessageEvent) ||
|
||||
typeof event.data !== "string"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDeployment = normalizeDeploymentId(event.data);
|
||||
if (!hasDeploymentChanged(currentDeployment.current, nextDeployment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const showReady = () => {
|
||||
if (updateAvailable.current) return;
|
||||
updateAvailable.current = true;
|
||||
toast("มีอัปเดตใหม่พร้อมใช้งาน", {
|
||||
id: UPDATE_TOAST_ID,
|
||||
@@ -45,10 +55,52 @@ export function DeploymentUpdateNotifier({
|
||||
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-status", receiveStatus);
|
||||
return () => {
|
||||
source.removeEventListener("deployment", receiveDeployment);
|
||||
source.removeEventListener("deployment-status", receiveStatus);
|
||||
source.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -47,6 +47,10 @@ kubectl get secret buzz-sheet-env \
|
||||
--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
|
||||
|
||||
Environment variables sourced from a Secret are read when a pod starts.
|
||||
|
||||
@@ -107,6 +107,12 @@ spec:
|
||||
configMapKeyRef:
|
||||
name: buzz-sheet-config
|
||||
key: NEXT_DEPLOYMENT_ID
|
||||
- name: DEPLOYMENT_WEBHOOK_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: buzz-sheet-env
|
||||
key: DEPLOYMENT_WEBHOOK_SECRET
|
||||
optional: true
|
||||
resources:
|
||||
requests:
|
||||
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";
|
||||
|
||||
type Subscriber = { client: ReturnType<typeof createRedisClient>; ready: Promise<void> };
|
||||
type StreamListener = {
|
||||
stream: EventStreamChannel;
|
||||
receive(value: string): void;
|
||||
};
|
||||
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.
|
||||
let operations = Promise.resolve();
|
||||
function ordered<T>(task: () => Promise<T>): Promise<T> {
|
||||
@@ -19,15 +23,15 @@ function getSubscriber(): Subscriber {
|
||||
const state: Subscriber = { client, ready: Promise.resolve() };
|
||||
subscriber = state;
|
||||
client.on("message", (channel, value) => {
|
||||
const event = parseInvalidationEvent(value);
|
||||
if (!event || redisEventChannel(expectedTopic(event)) !== channel) return;
|
||||
for (const stream of listeners.get(channel) ?? []) stream.send(event);
|
||||
for (const listener of listeners.get(channel) ?? []) listener.receive(value);
|
||||
});
|
||||
client.on("close", () => {
|
||||
if (subscriber !== state) return;
|
||||
subscriber = undefined;
|
||||
// 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();
|
||||
client.disconnect();
|
||||
});
|
||||
@@ -39,7 +43,18 @@ function getSubscriber(): Subscriber {
|
||||
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 state = getSubscriber();
|
||||
await state.ready;
|
||||
@@ -50,9 +65,9 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
await state.client.subscribe(channel);
|
||||
listeners.set(channel, new Set());
|
||||
}
|
||||
let eventStream: EventStreamChannel | undefined;
|
||||
let listener: StreamListener | undefined;
|
||||
const release = () => {
|
||||
if (eventStream) listeners.get(channel)?.delete(eventStream);
|
||||
if (listener) listeners.get(channel)?.delete(listener);
|
||||
void ordered(async () => {
|
||||
if (subscriber === state && listeners.get(channel)?.size === 0) {
|
||||
listeners.delete(channel);
|
||||
@@ -62,10 +77,21 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
};
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
eventStream = createEventStream({ signal, onClose: release });
|
||||
listeners.get(channel)?.add(eventStream);
|
||||
const eventStream = createEventStream({ signal, onClose: release });
|
||||
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;
|
||||
} catch (cause) {
|
||||
listener?.stream.close();
|
||||
release();
|
||||
throw cause;
|
||||
}
|
||||
@@ -73,10 +99,38 @@ export async function createRedisEventResponse(topic: string, signal: AbortSigna
|
||||
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() {
|
||||
const state = subscriber;
|
||||
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();
|
||||
state?.client.disconnect();
|
||||
}
|
||||
|
||||
@@ -156,7 +156,25 @@ describe("production deployment contract", () => {
|
||||
expect(notifier).toContain('new EventSource("/api/active")');
|
||||
expect(notifier).toContain("window.location.reload()");
|
||||
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",
|
||||
"NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
||||
"NEXT_DEPLOYMENT_ID",
|
||||
"DEPLOYMENT_WEBHOOK_SECRET",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"DISCORD_CHANNEL_ID",
|
||||
"DISCORD_LOG_CHANNEL_ID",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const { closeRedisEventStreams, createRedisEventResponse } = await import("@/lib/events/redis-stream");
|
||||
const { getLatestDeploymentStatus, publishDeploymentStatus } = await import("@/lib/deployment/repository");
|
||||
const { updateVisitorPresence } = await import("@/lib/presence");
|
||||
const { closeRedisClient, redisEventChannel } = await import("@/lib/redis/client");
|
||||
const { consumeRateLimit } = await import("@/lib/security/rate-limit");
|
||||
@@ -102,4 +103,17 @@ describeWithRedis("shared Redis security paths", () => {
|
||||
second.abort();
|
||||
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