create-windy 0.3.16 → 0.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +926 -723
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +1 -1
- package/template/README.md +3 -1
- package/template/apps/server/Dockerfile +1 -1
- package/template/apps/server/src/audit/contracts.ts +8 -0
- package/template/apps/server/src/audit/recovery-queue.test.ts +48 -1
- package/template/apps/server/src/audit/recovery-queue.ts +73 -4
- package/template/apps/server/src/audit/reliable-writer.test.ts +120 -2
- package/template/apps/server/src/audit/reliable-writer.ts +98 -34
- package/template/apps/web/Dockerfile +1 -1
- package/template/docs/platform/audit-reliability.md +33 -5
- package/template/package.json +3 -2
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
- `docs/development/custom-error-pages.md`:统一错误语义、Request ID 与业务页面
|
|
70
70
|
替换方式。
|
|
71
71
|
- `bun run dev`:复用 Docker 镜像启动 PostgreSQL、自动 migration、Server 与支持热更新的 Web。
|
|
72
|
-
- `bun run dev:build`:依赖、Dockerfile 或 lockfile
|
|
72
|
+
- `bun run dev:build`:依赖、Dockerfile 或 lockfile 变化后重建并启动开发环境;Web 与 Server 并发复用 BuildKit Bun 下载缓存,冷安装会周期输出不含凭据的心跳。
|
|
73
73
|
- `bun run dev:web`:启动 Web 开发服务。
|
|
74
74
|
- `bun run dev:server`:启动 Server 开发服务。
|
|
75
75
|
- `bun run lint`、`bun run typecheck`、`bun run test`、`bun run build`:执行基础验证;未选择 Oxc 时没有 lint 脚本。
|
package/template/README.md
CHANGED
|
@@ -31,7 +31,9 @@ create-windy 已创建仅供本机使用且被 Git 忽略的 `.env`,其中两
|
|
|
31
31
|
Drizzle migration,再启动 Server 与 Web;不要跳过 migration 后直接把 Server 连接到
|
|
32
32
|
空数据库。首次启动需要本机已经安装并启动 Docker 与 Docker Compose,并可能需要下载
|
|
33
33
|
基础镜像。后续 `bun run dev` 会复用已有镜像,不会重复构建;依赖、Dockerfile 或
|
|
34
|
-
`bun.lock` 变化后运行 `bun run dev:build
|
|
34
|
+
`bun.lock` 变化后运行 `bun run dev:build`。重建会让 Web 与 Server 并发复用
|
|
35
|
+
BuildKit 中的 Bun 包下载缓存,只下载新增或发生变化的依赖;冷安装每 15 秒输出一次
|
|
36
|
+
不含 registry 地址或凭据的进度心跳。
|
|
35
37
|
|
|
36
38
|
| 服务 | 地址 |
|
|
37
39
|
| ---- | ---- |
|
|
@@ -22,7 +22,7 @@ COPY packages/server-sdk/package.json packages/server-sdk/package.json
|
|
|
22
22
|
COPY packages/shared/package.json packages/shared/package.json
|
|
23
23
|
COPY packages/storage/package.json packages/storage/package.json
|
|
24
24
|
# windy-copy:deps end
|
|
25
|
-
RUN bun install
|
|
25
|
+
RUN --mount=type=cache,id=windy-bun-1.3.14,target=/root/.bun/install/cache,sharing=shared bun -e 'const child=Bun.spawn(["bun","install","--frozen-lockfile","--no-progress"],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});const heartbeat=setInterval(()=>console.log("[windy] Bun 依赖安装仍在进行"),15000);const exitCode=await child.exited;clearInterval(heartbeat);process.exit(exitCode)'
|
|
26
26
|
|
|
27
27
|
FROM deps AS runner
|
|
28
28
|
COPY drizzle.config.ts drizzle.config.ts
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export type AuditWritePolicy = "ordinary" | "critical";
|
|
2
2
|
|
|
3
|
+
export type AuditRecoveryFailure =
|
|
4
|
+
| "permission-denied"
|
|
5
|
+
| "invalid-format"
|
|
6
|
+
| "io-error"
|
|
7
|
+
| "unknown";
|
|
8
|
+
|
|
3
9
|
export interface AuditWriteStatus {
|
|
4
10
|
state: "healthy" | "degraded";
|
|
5
11
|
attempted: number;
|
|
@@ -10,6 +16,8 @@ export interface AuditWriteStatus {
|
|
|
10
16
|
dropped: number;
|
|
11
17
|
lastFailureAt?: string;
|
|
12
18
|
lastSuccessAt?: string;
|
|
19
|
+
recoveryFailure?: AuditRecoveryFailure;
|
|
20
|
+
lastRecoveryFailureAt?: string;
|
|
13
21
|
alert?: string;
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import type { AuditEvent } from "@southwind-ai/shared";
|
|
@@ -39,4 +39,51 @@ describe("FileAuditRecoveryQueue", () => {
|
|
|
39
39
|
await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory)),
|
|
40
40
|
).toEqual([]);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
test("合法空队列保持可读", async () => {
|
|
44
|
+
const directory = await mkdtemp(join(tmpdir(), "windy-audit-empty-"));
|
|
45
|
+
directories.push(directory);
|
|
46
|
+
const filePath = join(directory, "recovery.json");
|
|
47
|
+
const queue = new FileAuditRecoveryQueue(filePath);
|
|
48
|
+
|
|
49
|
+
await queue.replace([]);
|
|
50
|
+
|
|
51
|
+
await expect(queue.read()).resolves.toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test.each([
|
|
55
|
+
["缺少 formatVersion", { events: [] }],
|
|
56
|
+
["formatVersion 不受支持", { formatVersion: 2, events: [] }],
|
|
57
|
+
["缺少 events", { formatVersion: 1 }],
|
|
58
|
+
["events 不是数组", { formatVersion: 1, events: { secret: "不应泄露" } }],
|
|
59
|
+
[
|
|
60
|
+
"event 结构损坏",
|
|
61
|
+
{
|
|
62
|
+
formatVersion: 1,
|
|
63
|
+
events: [
|
|
64
|
+
{
|
|
65
|
+
id: "audit_invalid",
|
|
66
|
+
action: "auth.login",
|
|
67
|
+
outcome: "success",
|
|
68
|
+
actor: { id: "user_1", type: "invalid", name: "用户一" },
|
|
69
|
+
occurredAt: "2026-07-10T00:00:00.000Z",
|
|
70
|
+
secret: "不应泄露",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
])("%s 时以稳定无敏感内容错误拒绝", async (_, envelope) => {
|
|
76
|
+
const directory = await mkdtemp(join(tmpdir(), "windy-audit-invalid-"));
|
|
77
|
+
directories.push(directory);
|
|
78
|
+
const filePath = join(directory, "recovery.json");
|
|
79
|
+
await writeFile(filePath, JSON.stringify(envelope), "utf8");
|
|
80
|
+
|
|
81
|
+
const error = await new FileAuditRecoveryQueue(filePath)
|
|
82
|
+
.read()
|
|
83
|
+
.catch((failure: unknown) => failure);
|
|
84
|
+
|
|
85
|
+
expect(error).toBeInstanceOf(SyntaxError);
|
|
86
|
+
expect((error as Error).message).toBe("审计恢复队列文件格式无效");
|
|
87
|
+
expect(String(error)).not.toContain("不应泄露");
|
|
88
|
+
});
|
|
42
89
|
});
|
|
@@ -2,6 +2,9 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import type { AuditEvent } from "@southwind-ai/shared";
|
|
4
4
|
|
|
5
|
+
const recoveryQueueFormatVersion = 1;
|
|
6
|
+
const invalidRecoveryQueueMessage = "审计恢复队列文件格式无效";
|
|
7
|
+
|
|
5
8
|
export interface AuditRecoveryQueue {
|
|
6
9
|
read(): Promise<AuditEvent[]>;
|
|
7
10
|
replace(events: AuditEvent[]): Promise<void>;
|
|
@@ -17,6 +20,9 @@ export class FileAuditRecoveryQueue implements AuditRecoveryQueue {
|
|
|
17
20
|
return readEvents(value);
|
|
18
21
|
} catch (error) {
|
|
19
22
|
if (isMissingFile(error)) return [];
|
|
23
|
+
if (error instanceof SyntaxError) {
|
|
24
|
+
throw new SyntaxError(invalidRecoveryQueueMessage);
|
|
25
|
+
}
|
|
20
26
|
throw error;
|
|
21
27
|
}
|
|
22
28
|
}
|
|
@@ -26,7 +32,7 @@ export class FileAuditRecoveryQueue implements AuditRecoveryQueue {
|
|
|
26
32
|
const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
|
|
27
33
|
await writeFile(
|
|
28
34
|
temporaryPath,
|
|
29
|
-
JSON.stringify({ formatVersion:
|
|
35
|
+
JSON.stringify({ formatVersion: recoveryQueueFormatVersion, events }),
|
|
30
36
|
{ encoding: "utf8", mode: 0o600 },
|
|
31
37
|
);
|
|
32
38
|
await rename(temporaryPath, this.filePath);
|
|
@@ -49,9 +55,72 @@ export class MemoryAuditRecoveryQueue implements AuditRecoveryQueue {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function readEvents(value: unknown): AuditEvent[] {
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
if (
|
|
59
|
+
!isRecord(value) ||
|
|
60
|
+
value.formatVersion !== recoveryQueueFormatVersion ||
|
|
61
|
+
!Array.isArray(value.events) ||
|
|
62
|
+
!value.events.every(isAuditEvent)
|
|
63
|
+
) {
|
|
64
|
+
throw new SyntaxError(invalidRecoveryQueueMessage);
|
|
65
|
+
}
|
|
66
|
+
return value.events;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isAuditEvent(value: unknown): value is AuditEvent {
|
|
70
|
+
if (!isRecord(value) || !isRecord(value.actor)) return false;
|
|
71
|
+
const actor = value.actor;
|
|
72
|
+
return (
|
|
73
|
+
typeof value.id === "string" &&
|
|
74
|
+
typeof value.action === "string" &&
|
|
75
|
+
(value.outcome === "attempted" ||
|
|
76
|
+
value.outcome === "success" ||
|
|
77
|
+
value.outcome === "failure" ||
|
|
78
|
+
value.outcome === "denied") &&
|
|
79
|
+
typeof actor.id === "string" &&
|
|
80
|
+
(actor.type === "user" ||
|
|
81
|
+
actor.type === "service" ||
|
|
82
|
+
actor.type === "system") &&
|
|
83
|
+
typeof actor.name === "string" &&
|
|
84
|
+
isOptionalStringArray(actor.roleCodes) &&
|
|
85
|
+
isOptionalString(actor.ip) &&
|
|
86
|
+
isOptionalString(actor.userAgent) &&
|
|
87
|
+
isOptionalTarget(value.target) &&
|
|
88
|
+
isOptionalString(value.module) &&
|
|
89
|
+
isOptionalString(value.requestId) &&
|
|
90
|
+
isOptionalString(value.message) &&
|
|
91
|
+
isOptionalRecord(value.metadata) &&
|
|
92
|
+
typeof value.occurredAt === "string"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isOptionalTarget(value: unknown): boolean {
|
|
97
|
+
return (
|
|
98
|
+
value === undefined ||
|
|
99
|
+
(isRecord(value) &&
|
|
100
|
+
typeof value.type === "string" &&
|
|
101
|
+
isOptionalString(value.id) &&
|
|
102
|
+
isOptionalString(value.label))
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isOptionalString(value: unknown): boolean {
|
|
107
|
+
return value === undefined || typeof value === "string";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isOptionalStringArray(value: unknown): boolean {
|
|
111
|
+
return (
|
|
112
|
+
value === undefined ||
|
|
113
|
+
(Array.isArray(value) &&
|
|
114
|
+
value.every((item): item is string => typeof item === "string"))
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isOptionalRecord(value: unknown): boolean {
|
|
119
|
+
return value === undefined || isRecord(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
123
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
124
|
}
|
|
56
125
|
|
|
57
126
|
function isMissingFile(error: unknown): boolean {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import type { AuditEvent } from "@southwind-ai/shared";
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
FileAuditRecoveryQueue,
|
|
8
|
+
MemoryAuditRecoveryQueue,
|
|
9
|
+
} from "./recovery-queue.js";
|
|
4
10
|
import {
|
|
5
11
|
AuditWriteUnavailableError,
|
|
6
12
|
ReliableAuditWriter,
|
|
@@ -127,6 +133,117 @@ describe("ReliableAuditWriter", () => {
|
|
|
127
133
|
});
|
|
128
134
|
});
|
|
129
135
|
|
|
136
|
+
test("恢复队列权限错误时降级启动并记录稳定诊断", async () => {
|
|
137
|
+
const queue = new MemoryAuditRecoveryQueue();
|
|
138
|
+
queue.failure = Object.assign(new Error("sensitive queue path"), {
|
|
139
|
+
code: "EACCES",
|
|
140
|
+
});
|
|
141
|
+
const alerts: string[] = [];
|
|
142
|
+
const writer = new ReliableAuditWriter(
|
|
143
|
+
{ write: async () => undefined },
|
|
144
|
+
queue,
|
|
145
|
+
{
|
|
146
|
+
now: () => new Date("2026-07-28T10:00:00.000Z"),
|
|
147
|
+
onAlert: (status) => alerts.push(status.alert || ""),
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await expect(writer.recover()).resolves.toBeUndefined();
|
|
152
|
+
|
|
153
|
+
expect(writer.status()).toMatchObject({
|
|
154
|
+
state: "degraded",
|
|
155
|
+
recoveryFailure: "permission-denied",
|
|
156
|
+
lastRecoveryFailureAt: "2026-07-28T10:00:00.000Z",
|
|
157
|
+
alert: "关键审计恢复队列不可用:文件权限不足",
|
|
158
|
+
});
|
|
159
|
+
expect(alerts).toEqual(["关键审计恢复队列不可用:文件权限不足"]);
|
|
160
|
+
expect(JSON.stringify(writer.status())).not.toContain(
|
|
161
|
+
"sensitive queue path",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("恢复队列 JSON 损坏时降级启动且不泄露文件内容", async () => {
|
|
166
|
+
const directory = await mkdtemp(join(tmpdir(), "windy-audit-corrupt-"));
|
|
167
|
+
const filePath = join(directory, "recovery.json");
|
|
168
|
+
await writeFile(filePath, '{"secret":"must-not-leak"', "utf8");
|
|
169
|
+
const writer = new ReliableAuditWriter(
|
|
170
|
+
{ write: async () => undefined },
|
|
171
|
+
new FileAuditRecoveryQueue(filePath),
|
|
172
|
+
{ now: () => new Date("2026-07-28T10:01:00.000Z") },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await expect(writer.recover()).resolves.toBeUndefined();
|
|
177
|
+
|
|
178
|
+
expect(writer.status()).toMatchObject({
|
|
179
|
+
state: "degraded",
|
|
180
|
+
recoveryFailure: "invalid-format",
|
|
181
|
+
lastRecoveryFailureAt: "2026-07-28T10:01:00.000Z",
|
|
182
|
+
alert: "关键审计恢复队列不可用:队列文件格式无效",
|
|
183
|
+
});
|
|
184
|
+
expect(JSON.stringify(writer.status())).not.toContain("must-not-leak");
|
|
185
|
+
} finally {
|
|
186
|
+
await rm(directory, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("普通审计落库成功不能清除恢复队列故障", async () => {
|
|
191
|
+
const queue = new MemoryAuditRecoveryQueue();
|
|
192
|
+
queue.events = [{ ...event(), id: "audit_queued" }];
|
|
193
|
+
queue.failure = Object.assign(new Error("queue unavailable"), {
|
|
194
|
+
code: "EIO",
|
|
195
|
+
});
|
|
196
|
+
const writer = new ReliableAuditWriter(
|
|
197
|
+
{ write: async () => undefined },
|
|
198
|
+
queue,
|
|
199
|
+
{ now: () => new Date("2026-07-28T10:02:00.000Z") },
|
|
200
|
+
);
|
|
201
|
+
await writer.recover();
|
|
202
|
+
|
|
203
|
+
await writer.write(event(), "ordinary");
|
|
204
|
+
|
|
205
|
+
expect(writer.status()).toMatchObject({
|
|
206
|
+
state: "degraded",
|
|
207
|
+
persisted: 1,
|
|
208
|
+
recoveryFailure: "io-error",
|
|
209
|
+
lastRecoveryFailureAt: "2026-07-28T10:02:00.000Z",
|
|
210
|
+
alert: "关键审计恢复队列不可用:文件读写失败",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
queue.failure = undefined;
|
|
214
|
+
await writer.recover();
|
|
215
|
+
|
|
216
|
+
expect(writer.status()).toMatchObject({
|
|
217
|
+
state: "healthy",
|
|
218
|
+
persisted: 2,
|
|
219
|
+
queued: 0,
|
|
220
|
+
});
|
|
221
|
+
expect(queue.events).toEqual([]);
|
|
222
|
+
expect(writer.status().recoveryFailure).toBeUndefined();
|
|
223
|
+
expect(writer.status().lastRecoveryFailureAt).toBeUndefined();
|
|
224
|
+
expect(writer.status().alert).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("恢复队列重新可写后以关键事件待重放状态替代队列故障", async () => {
|
|
228
|
+
const queue = new MemoryAuditRecoveryQueue();
|
|
229
|
+
queue.failure = Object.assign(new Error("queue unavailable"), {
|
|
230
|
+
code: "EACCES",
|
|
231
|
+
});
|
|
232
|
+
const writer = failingWriter(queue);
|
|
233
|
+
await writer.recover();
|
|
234
|
+
queue.failure = undefined;
|
|
235
|
+
|
|
236
|
+
await writer.write(event(), "critical");
|
|
237
|
+
|
|
238
|
+
expect(writer.status()).toMatchObject({
|
|
239
|
+
state: "degraded",
|
|
240
|
+
queued: 1,
|
|
241
|
+
alert: "关键审计已进入可恢复队列",
|
|
242
|
+
});
|
|
243
|
+
expect(writer.status().recoveryFailure).toBeUndefined();
|
|
244
|
+
expect(writer.status().lastRecoveryFailureAt).toBeUndefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
130
247
|
test("当前事件已落库但恢复文件故障时只告警且不误报丢弃", async () => {
|
|
131
248
|
const queue = new MemoryAuditRecoveryQueue();
|
|
132
249
|
let databaseAvailable = false;
|
|
@@ -152,7 +269,8 @@ describe("ReliableAuditWriter", () => {
|
|
|
152
269
|
persisted: 1,
|
|
153
270
|
queued: 1,
|
|
154
271
|
dropped: 0,
|
|
155
|
-
|
|
272
|
+
recoveryFailure: "unknown",
|
|
273
|
+
alert: "关键审计恢复队列不可用:未知错误",
|
|
156
274
|
});
|
|
157
275
|
});
|
|
158
276
|
|
|
@@ -2,6 +2,7 @@ import type { AuditEvent } from "@southwind-ai/shared";
|
|
|
2
2
|
import type { AuditEventWriter } from "../runtime.js";
|
|
3
3
|
import {
|
|
4
4
|
AuditWriteUnavailableError,
|
|
5
|
+
type AuditRecoveryFailure,
|
|
5
6
|
type AuditWritePolicy,
|
|
6
7
|
type AuditWriteStatus,
|
|
7
8
|
} from "./contracts.js";
|
|
@@ -69,45 +70,62 @@ export class ReliableAuditWriter implements AuditEventWriter {
|
|
|
69
70
|
await this.handlePersistenceFailure(redacted, policy);
|
|
70
71
|
return;
|
|
71
72
|
}
|
|
72
|
-
if (this.current.queued > 0) {
|
|
73
|
-
|
|
74
|
-
await this.recover();
|
|
75
|
-
} catch {
|
|
76
|
-
// 当前事件已经持久化;恢复队列故障只触发告警,不回滚成功写入。
|
|
77
|
-
}
|
|
73
|
+
if (this.current.queued > 0 || this.current.recoveryFailure) {
|
|
74
|
+
await this.recover();
|
|
78
75
|
}
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
async recover(): Promise<void> {
|
|
82
|
-
|
|
83
|
-
await this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
await this.queue.replace(pending);
|
|
101
|
-
this.current.queued = pending.length;
|
|
102
|
-
if (pending.length) {
|
|
103
|
-
this.markDegraded("关键审计恢复仍在等待持久化");
|
|
104
|
-
} else {
|
|
105
|
-
this.refreshState();
|
|
79
|
+
await this.withQueueLock(async () => {
|
|
80
|
+
const events = await this.readRecoveryQueue();
|
|
81
|
+
if (!events) return;
|
|
82
|
+
if (!events.length) {
|
|
83
|
+
this.current.queued = 0;
|
|
84
|
+
this.clearRecoveryFailure();
|
|
85
|
+
this.refreshState();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const pending: AuditEvent[] = [];
|
|
89
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
90
|
+
try {
|
|
91
|
+
await this.persistWithRetry(events[index]!);
|
|
92
|
+
this.markSuccess();
|
|
93
|
+
} catch {
|
|
94
|
+
pending.push(...events.slice(index));
|
|
95
|
+
break;
|
|
106
96
|
}
|
|
107
|
-
}
|
|
97
|
+
}
|
|
98
|
+
if (!(await this.replaceRecoveryQueue(pending, events.length))) return;
|
|
99
|
+
this.current.queued = pending.length;
|
|
100
|
+
this.clearRecoveryFailure();
|
|
101
|
+
if (pending.length) {
|
|
102
|
+
this.markDegraded("关键审计恢复仍在等待持久化");
|
|
103
|
+
} else {
|
|
104
|
+
this.refreshState();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async readRecoveryQueue(): Promise<AuditEvent[] | undefined> {
|
|
110
|
+
try {
|
|
111
|
+
return await this.queue.read();
|
|
108
112
|
} catch (error) {
|
|
109
|
-
this.
|
|
110
|
-
|
|
113
|
+
this.markRecoveryDegraded(error);
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async replaceRecoveryQueue(
|
|
119
|
+
events: AuditEvent[],
|
|
120
|
+
currentQueueSize: number,
|
|
121
|
+
): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
await this.queue.replace(events);
|
|
124
|
+
return true;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
this.current.queued = currentQueueSize;
|
|
127
|
+
this.markRecoveryDegraded(error);
|
|
128
|
+
return false;
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
131
|
|
|
@@ -133,6 +151,7 @@ export class ReliableAuditWriter implements AuditEventWriter {
|
|
|
133
151
|
await this.queue.replace([...events, event]);
|
|
134
152
|
this.current.queued = events.length + 1;
|
|
135
153
|
});
|
|
154
|
+
this.clearRecoveryFailure();
|
|
136
155
|
this.markDegraded("关键审计已进入可恢复队列");
|
|
137
156
|
} catch {
|
|
138
157
|
this.markDegraded("关键审计写入和恢复队列均不可用");
|
|
@@ -169,8 +188,20 @@ export class ReliableAuditWriter implements AuditEventWriter {
|
|
|
169
188
|
this.options.onAlert?.(this.status());
|
|
170
189
|
}
|
|
171
190
|
|
|
191
|
+
private markRecoveryDegraded(error: unknown): void {
|
|
192
|
+
const recoveryFailure = classifyRecoveryFailure(error);
|
|
193
|
+
this.current.recoveryFailure = recoveryFailure;
|
|
194
|
+
this.current.lastRecoveryFailureAt = this.now().toISOString();
|
|
195
|
+
this.markDegraded(recoveryFailureAlert(recoveryFailure));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private clearRecoveryFailure(): void {
|
|
199
|
+
delete this.current.recoveryFailure;
|
|
200
|
+
delete this.current.lastRecoveryFailureAt;
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
private refreshState(): void {
|
|
173
|
-
if (this.current.queued === 0) {
|
|
204
|
+
if (this.current.queued === 0 && !this.current.recoveryFailure) {
|
|
174
205
|
this.current.state = "healthy";
|
|
175
206
|
delete this.current.alert;
|
|
176
207
|
}
|
|
@@ -199,3 +230,36 @@ function boundedInteger(
|
|
|
199
230
|
function sleep(milliseconds: number): Promise<void> {
|
|
200
231
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
201
232
|
}
|
|
233
|
+
|
|
234
|
+
function classifyRecoveryFailure(error: unknown): AuditRecoveryFailure {
|
|
235
|
+
if (error instanceof SyntaxError) {
|
|
236
|
+
return "invalid-format";
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
typeof error === "object" &&
|
|
240
|
+
error !== null &&
|
|
241
|
+
"code" in error &&
|
|
242
|
+
(error.code === "EACCES" || error.code === "EPERM")
|
|
243
|
+
) {
|
|
244
|
+
return "permission-denied";
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
typeof error === "object" &&
|
|
248
|
+
error !== null &&
|
|
249
|
+
"code" in error &&
|
|
250
|
+
typeof error.code === "string"
|
|
251
|
+
) {
|
|
252
|
+
return "io-error";
|
|
253
|
+
}
|
|
254
|
+
return "unknown";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function recoveryFailureAlert(failure: AuditRecoveryFailure): string {
|
|
258
|
+
const detail = {
|
|
259
|
+
"permission-denied": "文件权限不足",
|
|
260
|
+
"invalid-format": "队列文件格式无效",
|
|
261
|
+
"io-error": "文件读写失败",
|
|
262
|
+
unknown: "未知错误",
|
|
263
|
+
} satisfies Record<AuditRecoveryFailure, string>;
|
|
264
|
+
return `关键审计恢复队列不可用:${detail[failure]}`;
|
|
265
|
+
}
|
|
@@ -22,7 +22,7 @@ COPY packages/server-sdk/package.json packages/server-sdk/package.json
|
|
|
22
22
|
COPY packages/shared/package.json packages/shared/package.json
|
|
23
23
|
COPY packages/storage/package.json packages/storage/package.json
|
|
24
24
|
# windy-copy:deps end
|
|
25
|
-
RUN bun install
|
|
25
|
+
RUN --mount=type=cache,id=windy-bun-1.3.14,target=/root/.bun/install/cache,sharing=shared bun -e 'const child=Bun.spawn(["bun","install","--frozen-lockfile","--no-progress"],{stdin:"inherit",stdout:"inherit",stderr:"inherit"});const heartbeat=setInterval(()=>console.log("[windy] Bun 依赖安装仍在进行"),15000);const exitCode=await child.exited;clearInterval(heartbeat);process.exit(exitCode)'
|
|
26
26
|
|
|
27
27
|
FROM deps AS build
|
|
28
28
|
# windy-copy:build begin
|
|
@@ -79,9 +79,19 @@ License 上传仍只记录候选摘要、可识别的 License ID、公钥 ID 和
|
|
|
79
79
|
2. Server 启动、以及后续任一审计写入恢复成功时,会顺序重放队列。
|
|
80
80
|
3. PostgreSQL 以事件 ID 幂等插入,连接超时后即使数据库实际已提交,重放也不会生成重复事件。
|
|
81
81
|
4. 只有成功持久化的前缀会从队列移除;首次失败事件及其后续事件继续保留。
|
|
82
|
+
5. 启动读取队列遇到权限、JSON 格式或其它文件 I/O 错误时,Server 仍以审计
|
|
83
|
+
`degraded` 状态启动并告警,不让审计恢复文件阻断全部无关业务。
|
|
84
|
+
未知 `formatVersion`、缺失或非数组 `events`、损坏的事件结构都属于格式错误;
|
|
85
|
+
平台不会把它们静默解释为空队列。
|
|
86
|
+
6. 恢复故障只有在队列重新成功读取、且非空队列成功原子改写后才清除;单次普通审计
|
|
87
|
+
成功写入 PostgreSQL 不能把仍不可读的恢复队列误报为健康。
|
|
82
88
|
|
|
83
89
|
恢复文件所在目录必须挂载到持久卷,并只允许 Server 运行账号访问。不要把它放到容器临时层、共享静态目录或备份公开目录。
|
|
84
90
|
|
|
91
|
+
启动降级不改变两类写入策略:普通请求继续按 best-effort 审计语义执行;关键动作优先
|
|
92
|
+
写 PostgreSQL,主库持续失败时再写恢复队列。只有主库与恢复队列同时不可用时,关键
|
|
93
|
+
动作仍然 fail closed,不会因为 Server 已监听端口而降级放行。
|
|
94
|
+
|
|
85
95
|
## 可观测状态
|
|
86
96
|
|
|
87
97
|
`GET /api/health` 的 `audit` 字段返回非敏感运行状态:
|
|
@@ -97,7 +107,9 @@ License 上传仍只记录候选摘要、可识别的 License ID、公钥 ID 和
|
|
|
97
107
|
"dropped": 1,
|
|
98
108
|
"lastFailureAt": "2026-07-10T08:00:00.000Z",
|
|
99
109
|
"lastSuccessAt": "2026-07-10T07:59:00.000Z",
|
|
100
|
-
"
|
|
110
|
+
"recoveryFailure": "permission-denied",
|
|
111
|
+
"lastRecoveryFailureAt": "2026-07-10T08:00:01.000Z",
|
|
112
|
+
"alert": "关键审计恢复队列不可用:文件权限不足"
|
|
101
113
|
}
|
|
102
114
|
```
|
|
103
115
|
|
|
@@ -105,6 +117,10 @@ License 上传仍只记录候选摘要、可识别的 License ID、公钥 ID 和
|
|
|
105
117
|
- `retries` 是已经执行的重试次数。
|
|
106
118
|
- `queued` 是当前仍待恢复的关键事件数。
|
|
107
119
|
- `dropped` 是 PostgreSQL 持续失败后未进入持久队列的普通事件数。
|
|
120
|
+
- `recoveryFailure` 是不包含路径或事件正文的稳定故障分类:
|
|
121
|
+
`permission-denied`、`invalid-format`、`io-error` 或 `unknown`。
|
|
122
|
+
- `lastRecoveryFailureAt` 是最近一次恢复队列操作失败的时间;队列不可读时
|
|
123
|
+
`queued=0` 只代表没有已知计数,不能解释为文件内没有待恢复事件。
|
|
108
124
|
- `state=degraded` 或 `queued/dropped > 0` 应触发运维告警。
|
|
109
125
|
|
|
110
126
|
进入降级状态时 Server 同时写入结构化 error 日志,日志只包含上述状态,不包含事件正文。
|
|
@@ -130,10 +146,22 @@ License 上传仍只记录候选摘要、可识别的 License ID、公钥 ID 和
|
|
|
130
146
|
|
|
131
147
|
1. 检查 `/api/health` 的 `audit` 状态和 Server 的“审计持久化进入降级状态”日志。
|
|
132
148
|
2. 检查 PostgreSQL 连接、连接池容量、`audit_logs` 表和磁盘容量。
|
|
133
|
-
3.
|
|
134
|
-
|
|
135
|
-
|
|
149
|
+
3. 对恢复文件或持久卷先做只读备份或卷快照,再进行权限和内容处置。平台不会自动
|
|
150
|
+
丢弃、清空或覆盖不可读、格式损坏的队列,因为其中可能包含尚未落库的关键事件。
|
|
151
|
+
4. `permission-denied` 时把目录和文件所有权恢复为 Server 运行账号,保持文件
|
|
152
|
+
`0600`、目录不对其它账号开放;不要用全局可写权限绕过问题。
|
|
153
|
+
5. `invalid-format` 时保留原始备份,把损坏文件人工隔离到 Server 不会继续写入的位置,
|
|
154
|
+
离线核对可恢复事件后再修复或重建有效队列。未经审阅不要直接截断文件或创建空文件
|
|
155
|
+
覆盖原内容。
|
|
156
|
+
6. `io-error` 时检查挂载、只读文件系统、磁盘容量和底层存储健康;修复后执行一个普通
|
|
157
|
+
的已审计请求触发恢复,也可以重启 Server 在启动阶段恢复。
|
|
158
|
+
7. 只有 `recoveryFailure` 已清除、`queued=0` 且状态恢复为 `healthy` 后才解除告警。
|
|
159
|
+
`dropped` 是累计计数,不会自动归零,应结合故障窗口内的进程内事件和业务日志进行
|
|
160
|
+
人工补查。
|
|
136
161
|
|
|
137
162
|
## 验证覆盖
|
|
138
163
|
|
|
139
|
-
|
|
164
|
+
自动测试覆盖瞬时故障后成功、退避上限、普通事件持续失败、关键事件入队、数据库恢复
|
|
165
|
+
重放、启动读取权限错误和损坏 JSON 后降级、普通写入不得误清恢复故障、数据库与队列
|
|
166
|
+
同时失败、队列内容深层脱敏、License 损坏文件恢复上传入队,以及 PostgreSQL 事件 ID
|
|
167
|
+
幂等重放。真实 PostgreSQL 集成测试使用 `AUDIT_TEST_DATABASE_URL` 显式启用。
|
package/template/package.json
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"ops:db": "bun run scripts/production-database.ts",
|
|
51
51
|
"ops:release": "bun run scripts/production-release.ts",
|
|
52
52
|
"test:merge": "bun run scripts/merge-gate.ts",
|
|
53
|
-
"release:verify": "bun run scripts/release-verify.ts",
|
|
53
|
+
"release:verify": "bun run scripts/release-verify.ts --full",
|
|
54
54
|
"release:candidate": "bun run scripts/release-candidate.ts",
|
|
55
55
|
"release:verify:full": "bun run scripts/release-verify.ts --full",
|
|
56
56
|
"test:packages:pack": "bun run scripts/platform-package-release.ts",
|
|
@@ -70,7 +70,8 @@
|
|
|
70
70
|
"test": "bun test --path-ignore-patterns='packages/create-windy/template/**' && bun run --cwd apps/web test"
|
|
71
71
|
},
|
|
72
72
|
"lint-staged": {
|
|
73
|
-
"*.{
|
|
73
|
+
"*.{css,html,json,jsonc,less,md,mdx,scss,yaml,yml}": "oxfmt --write",
|
|
74
|
+
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx,vue}": [
|
|
74
75
|
"oxfmt --write",
|
|
75
76
|
"oxlint"
|
|
76
77
|
]
|