@vibesdotdev/infra-cloudflare 0.0.1
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/README.md +107 -0
- package/SPEC.md +166 -0
- package/dist/cloudflare.plugin.d.ts +73 -0
- package/dist/cloudflare.plugin.d.ts.map +1 -0
- package/dist/cloudflare.plugin.js +334 -0
- package/dist/cloudflare.plugin.js.map +1 -0
- package/dist/implementations/alerts.descriptor.d.ts +13 -0
- package/dist/implementations/alerts.descriptor.d.ts.map +1 -0
- package/dist/implementations/alerts.descriptor.js +30 -0
- package/dist/implementations/alerts.descriptor.js.map +1 -0
- package/dist/implementations/alerts.impl.d.ts +35 -0
- package/dist/implementations/alerts.impl.d.ts.map +1 -0
- package/dist/implementations/alerts.impl.js +283 -0
- package/dist/implementations/alerts.impl.js.map +1 -0
- package/dist/implementations/kv.impl.d.ts +29 -0
- package/dist/implementations/kv.impl.d.ts.map +1 -0
- package/dist/implementations/kv.impl.js +36 -0
- package/dist/implementations/kv.impl.js.map +1 -0
- package/dist/implementations/logs.descriptor.d.ts +15 -0
- package/dist/implementations/logs.descriptor.d.ts.map +1 -0
- package/dist/implementations/logs.descriptor.js +26 -0
- package/dist/implementations/logs.descriptor.js.map +1 -0
- package/dist/implementations/logs.impl.d.ts +108 -0
- package/dist/implementations/logs.impl.d.ts.map +1 -0
- package/dist/implementations/logs.impl.js +154 -0
- package/dist/implementations/logs.impl.js.map +1 -0
- package/dist/implementations/observability.descriptor.d.ts +9 -0
- package/dist/implementations/observability.descriptor.d.ts.map +1 -0
- package/dist/implementations/observability.descriptor.js +22 -0
- package/dist/implementations/observability.descriptor.js.map +1 -0
- package/dist/implementations/observability.impl.d.ts +35 -0
- package/dist/implementations/observability.impl.d.ts.map +1 -0
- package/dist/implementations/observability.impl.js +229 -0
- package/dist/implementations/observability.impl.js.map +1 -0
- package/dist/implementations/pages.impl.d.ts +98 -0
- package/dist/implementations/pages.impl.d.ts.map +1 -0
- package/dist/implementations/pages.impl.js +132 -0
- package/dist/implementations/pages.impl.js.map +1 -0
- package/dist/implementations/queues.impl.d.ts +29 -0
- package/dist/implementations/queues.impl.d.ts.map +1 -0
- package/dist/implementations/queues.impl.js +34 -0
- package/dist/implementations/queues.impl.js.map +1 -0
- package/dist/implementations/r2.impl.d.ts +31 -0
- package/dist/implementations/r2.impl.d.ts.map +1 -0
- package/dist/implementations/r2.impl.js +41 -0
- package/dist/implementations/r2.impl.js.map +1 -0
- package/dist/implementations/rum.descriptor.d.ts +13 -0
- package/dist/implementations/rum.descriptor.d.ts.map +1 -0
- package/dist/implementations/rum.descriptor.js +32 -0
- package/dist/implementations/rum.descriptor.js.map +1 -0
- package/dist/implementations/rum.impl.d.ts +34 -0
- package/dist/implementations/rum.impl.d.ts.map +1 -0
- package/dist/implementations/rum.impl.js +153 -0
- package/dist/implementations/rum.impl.js.map +1 -0
- package/dist/implementations/web-app.impl.d.ts +294 -0
- package/dist/implementations/web-app.impl.d.ts.map +1 -0
- package/dist/implementations/web-app.impl.js +208 -0
- package/dist/implementations/web-app.impl.js.map +1 -0
- package/dist/implementations/workers.impl.d.ts +157 -0
- package/dist/implementations/workers.impl.d.ts.map +1 -0
- package/dist/implementations/workers.impl.js +247 -0
- package/dist/implementations/workers.impl.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/pages.d.ts +9 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +9 -0
- package/dist/pages.js.map +1 -0
- package/dist/regen.d.ts +58 -0
- package/dist/regen.d.ts.map +1 -0
- package/dist/regen.js +69 -0
- package/dist/regen.js.map +1 -0
- package/dist/secrets/cloudflare-api.descriptor.d.ts +18 -0
- package/dist/secrets/cloudflare-api.descriptor.d.ts.map +1 -0
- package/dist/secrets/cloudflare-api.descriptor.js +32 -0
- package/dist/secrets/cloudflare-api.descriptor.js.map +1 -0
- package/dist/secrets/cloudflare-api.impl.d.ts +30 -0
- package/dist/secrets/cloudflare-api.impl.d.ts.map +1 -0
- package/dist/secrets/cloudflare-api.impl.js +111 -0
- package/dist/secrets/cloudflare-api.impl.js.map +1 -0
- package/dist/secrets/cloudflare-secrets-store.descriptor.d.ts +10 -0
- package/dist/secrets/cloudflare-secrets-store.descriptor.d.ts.map +1 -0
- package/dist/secrets/cloudflare-secrets-store.descriptor.js +24 -0
- package/dist/secrets/cloudflare-secrets-store.descriptor.js.map +1 -0
- package/dist/secrets/cloudflare-secrets-store.impl.d.ts +27 -0
- package/dist/secrets/cloudflare-secrets-store.impl.d.ts.map +1 -0
- package/dist/secrets/cloudflare-secrets-store.impl.js +72 -0
- package/dist/secrets/cloudflare-secrets-store.impl.js.map +1 -0
- package/dist/secrets/index.d.ts +6 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +6 -0
- package/dist/secrets/index.js.map +1 -0
- package/dist/secrets/resolve-cf-credentials.d.ts +18 -0
- package/dist/secrets/resolve-cf-credentials.d.ts.map +1 -0
- package/dist/secrets/resolve-cf-credentials.js +57 -0
- package/dist/secrets/resolve-cf-credentials.js.map +1 -0
- package/dist/web-app.d.ts +11 -0
- package/dist/web-app.d.ts.map +1 -0
- package/dist/web-app.js +11 -0
- package/dist/web-app.js.map +1 -0
- package/package.json +153 -0
- package/src/cloudflare.plugin.ts +477 -0
- package/src/implementations/alerts.descriptor.ts +33 -0
- package/src/implementations/alerts.impl.ts +332 -0
- package/src/implementations/kv.impl.ts +51 -0
- package/src/implementations/logs.descriptor.ts +29 -0
- package/src/implementations/logs.impl.ts +201 -0
- package/src/implementations/observability.descriptor.ts +25 -0
- package/src/implementations/observability.impl.ts +307 -0
- package/src/implementations/pages.impl.ts +189 -0
- package/src/implementations/queues.impl.ts +48 -0
- package/src/implementations/r2.impl.ts +58 -0
- package/src/implementations/rum.descriptor.ts +35 -0
- package/src/implementations/rum.impl.ts +192 -0
- package/src/implementations/web-app.impl.ts +494 -0
- package/src/implementations/workers.impl.ts +336 -0
- package/src/index.ts +60 -0
- package/src/pages.ts +18 -0
- package/src/regen.ts +87 -0
- package/src/secrets/cloudflare-api.descriptor.ts +35 -0
- package/src/secrets/cloudflare-api.impl.ts +131 -0
- package/src/secrets/cloudflare-secrets-store.descriptor.ts +27 -0
- package/src/secrets/cloudflare-secrets-store.impl.ts +87 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/resolve-cf-credentials.ts +63 -0
- package/src/web-app.ts +32 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Alerts Implementation
|
|
3
|
+
*
|
|
4
|
+
* Maps the provider-agnostic `infra/alerts` kind onto Cloudflare's
|
|
5
|
+
* Notifications API (`/accounts/:account/alerting/v3/policies`).
|
|
6
|
+
*
|
|
7
|
+
* Native type translation:
|
|
8
|
+
* budget → billing_budget_alert
|
|
9
|
+
* usage → billing_usage_alert
|
|
10
|
+
* error-rate → workers_alert (filter: outcome=exception)
|
|
11
|
+
* latency → (not supported by CF — adapter throws on create)
|
|
12
|
+
* custom → adapter passes through the `native_alert_type` filter
|
|
13
|
+
*
|
|
14
|
+
* Credentials: prefers a scoped CLOUDFLARE_NOTIFICATIONS_API_TOKEN (with
|
|
15
|
+
* notifications:edit), falls back to CLOUDFLARE_API_TOKEN for read-only
|
|
16
|
+
* operations. Account id from CLOUDFLARE_ACCOUNT_ID. Both resolve via
|
|
17
|
+
* the standard runtime secrets chain (encrypted-local + env file +
|
|
18
|
+
* CF Secrets Store).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as z from 'zod/v4';
|
|
22
|
+
import type { AlertPolicy, AlertsImplementation } from '@vibesdotdev/infra-core/kinds';
|
|
23
|
+
import { resolveAdapterCredential } from '@vibesdotdev/infra-core';
|
|
24
|
+
|
|
25
|
+
export const CloudflareAlertsDescriptorSchema = z.object({
|
|
26
|
+
kind: z.literal('infra/alerts'),
|
|
27
|
+
id: z.string().min(1),
|
|
28
|
+
name: z.string().min(1),
|
|
29
|
+
description: z.string().optional(),
|
|
30
|
+
adapter: z.literal('cloudflare-alerts'),
|
|
31
|
+
adapterConfig: z.record(z.string(), z.unknown()).optional(),
|
|
32
|
+
// Default to 'local' to match the infra-core kind schema. CLI ops
|
|
33
|
+
// resolve credentials from the local vibes secrets vault; Workers
|
|
34
|
+
// in production receive credentials via CF Secrets Store bindings
|
|
35
|
+
// without going through this adapter.
|
|
36
|
+
environment: z.string().default('local'),
|
|
37
|
+
policies: z.array(z.unknown()).default([])
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type CloudflareAlertsDescriptorInput = z.input<typeof CloudflareAlertsDescriptorSchema>;
|
|
41
|
+
export type CloudflareAlertsDescriptor = z.infer<typeof CloudflareAlertsDescriptorSchema>;
|
|
42
|
+
|
|
43
|
+
interface CfApiBody<T> {
|
|
44
|
+
success: boolean;
|
|
45
|
+
result: T;
|
|
46
|
+
errors?: Array<{ code: number; message: string }>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CfPolicy {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
alert_type: string;
|
|
54
|
+
enabled: boolean;
|
|
55
|
+
filters?: Record<string, string[]>;
|
|
56
|
+
mechanisms?: { email?: Array<{ id: string }>; webhooks?: Array<{ id: string }>; pagerduty?: Array<{ id: string }> };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Map provider-agnostic AlertType → CF native alert_type. */
|
|
60
|
+
function nativeAlertType(policy: AlertPolicy): string {
|
|
61
|
+
switch (policy.type) {
|
|
62
|
+
case 'budget':
|
|
63
|
+
return 'billing_budget_alert';
|
|
64
|
+
case 'usage':
|
|
65
|
+
return 'billing_usage_alert';
|
|
66
|
+
case 'error-rate':
|
|
67
|
+
// CF exposes Worker error alerts under a single type. The filters
|
|
68
|
+
// narrow scope (worker_name, etc.).
|
|
69
|
+
return 'workers_alert';
|
|
70
|
+
case 'latency':
|
|
71
|
+
throw new Error(
|
|
72
|
+
'cloudflare-alerts: latency alerts are not supported by CF Notifications. ' +
|
|
73
|
+
'Use traces / Workers Observability dashboards instead.'
|
|
74
|
+
);
|
|
75
|
+
case 'custom': {
|
|
76
|
+
const explicit = policy.filters['native_alert_type'];
|
|
77
|
+
if (!explicit) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'cloudflare-alerts: type=custom requires filters.native_alert_type to be set'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return explicit;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Map provider-agnostic policy → CF body shape. */
|
|
88
|
+
function toCfPayload(policy: AlertPolicy): Record<string, unknown> {
|
|
89
|
+
const alertType = nativeAlertType(policy);
|
|
90
|
+
const filters: Record<string, string[]> = {};
|
|
91
|
+
|
|
92
|
+
// Translate the threshold + filters into CF's filters shape.
|
|
93
|
+
switch (policy.type) {
|
|
94
|
+
case 'budget':
|
|
95
|
+
filters.total_spend_dollars = [String(policy.threshold)];
|
|
96
|
+
break;
|
|
97
|
+
case 'usage':
|
|
98
|
+
if (!policy.filters.product) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
"cloudflare-alerts: type=usage requires filters.product. Confirmed valid slugs (probed against /alerting/v3/policies, 2026-05): " +
|
|
101
|
+
'worker_requests, worker_kv_storage, worker_kv_reads, worker_kv_writes, ' +
|
|
102
|
+
'd1_storage, d1_rows_read, d1_rows_written, r2_storage. ' +
|
|
103
|
+
'Workers Observability is not exposed as a billing_usage_alert product.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
filters.product = [policy.filters.product];
|
|
107
|
+
filters.limit = [String(policy.threshold)];
|
|
108
|
+
break;
|
|
109
|
+
case 'error-rate':
|
|
110
|
+
if (policy.filters.worker) filters.scriptName = [policy.filters.worker];
|
|
111
|
+
filters.error_rate_percentage = [String(policy.threshold)];
|
|
112
|
+
break;
|
|
113
|
+
case 'custom': {
|
|
114
|
+
// Pass remaining filters verbatim; drop the meta key.
|
|
115
|
+
for (const [k, v] of Object.entries(policy.filters)) {
|
|
116
|
+
if (k === 'native_alert_type') continue;
|
|
117
|
+
filters[k] = [v];
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const mechanisms: Record<string, Array<{ id: string }>> = {};
|
|
124
|
+
for (const m of policy.mechanisms) {
|
|
125
|
+
switch (m.kind) {
|
|
126
|
+
case 'email':
|
|
127
|
+
(mechanisms.email ??= []).push({ id: m.target });
|
|
128
|
+
break;
|
|
129
|
+
case 'webhook':
|
|
130
|
+
(mechanisms.webhooks ??= []).push({ id: m.target });
|
|
131
|
+
break;
|
|
132
|
+
case 'pagerduty':
|
|
133
|
+
(mechanisms.pagerduty ??= []).push({ id: m.target });
|
|
134
|
+
break;
|
|
135
|
+
case 'slack':
|
|
136
|
+
// CF treats Slack as a webhook destination
|
|
137
|
+
(mechanisms.webhooks ??= []).push({ id: m.target });
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: policy.name,
|
|
144
|
+
description: policy.description,
|
|
145
|
+
enabled: policy.enabled,
|
|
146
|
+
alert_type: alertType,
|
|
147
|
+
filters,
|
|
148
|
+
mechanisms
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Reverse-map a CF policy → AlertPolicy (best-effort; filters are lossy). */
|
|
153
|
+
function fromCfPolicy(cf: CfPolicy): AlertPolicy & { providerId: string } {
|
|
154
|
+
let type: AlertPolicy['type'] = 'custom';
|
|
155
|
+
let threshold = 0;
|
|
156
|
+
const filters: Record<string, string> = {};
|
|
157
|
+
|
|
158
|
+
switch (cf.alert_type) {
|
|
159
|
+
case 'billing_budget_alert':
|
|
160
|
+
type = 'budget';
|
|
161
|
+
threshold = Number(cf.filters?.total_spend_dollars?.[0] ?? 0);
|
|
162
|
+
break;
|
|
163
|
+
case 'billing_usage_alert':
|
|
164
|
+
type = 'usage';
|
|
165
|
+
threshold = Number(cf.filters?.limit?.[0] ?? 0);
|
|
166
|
+
if (cf.filters?.product?.[0]) filters.product = cf.filters.product[0]!;
|
|
167
|
+
break;
|
|
168
|
+
case 'workers_alert':
|
|
169
|
+
type = 'error-rate';
|
|
170
|
+
threshold = Number(cf.filters?.error_rate_percentage?.[0] ?? 0);
|
|
171
|
+
if (cf.filters?.scriptName?.[0]) filters.worker = cf.filters.scriptName[0]!;
|
|
172
|
+
break;
|
|
173
|
+
default:
|
|
174
|
+
type = 'custom';
|
|
175
|
+
filters.native_alert_type = cf.alert_type;
|
|
176
|
+
for (const [k, v] of Object.entries(cf.filters ?? {})) {
|
|
177
|
+
if (Array.isArray(v) && v[0] != null) filters[k] = String(v[0]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const mechanisms: AlertPolicy['mechanisms'] = [];
|
|
182
|
+
for (const e of cf.mechanisms?.email ?? []) mechanisms.push({ kind: 'email', target: e.id });
|
|
183
|
+
for (const w of cf.mechanisms?.webhooks ?? []) {
|
|
184
|
+
// Heuristic: hooks.slack.com → Slack; otherwise generic webhook
|
|
185
|
+
const target = w.id;
|
|
186
|
+
if (target.includes('hooks.slack.com')) mechanisms.push({ kind: 'slack', target });
|
|
187
|
+
else if (target.startsWith('http')) mechanisms.push({ kind: 'webhook', target });
|
|
188
|
+
else mechanisms.push({ kind: 'pagerduty', target });
|
|
189
|
+
}
|
|
190
|
+
for (const p of cf.mechanisms?.pagerduty ?? []) mechanisms.push({ kind: 'pagerduty', target: p.id });
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
providerId: cf.id,
|
|
194
|
+
id: cf.id,
|
|
195
|
+
name: cf.name,
|
|
196
|
+
type,
|
|
197
|
+
threshold,
|
|
198
|
+
filters,
|
|
199
|
+
mechanisms,
|
|
200
|
+
enabled: cf.enabled,
|
|
201
|
+
description: cf.description
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
class CloudflareAlertsImplementation implements AlertsImplementation {
|
|
206
|
+
readonly id = 'cloudflare-alerts';
|
|
207
|
+
readonly descriptor: CloudflareAlertsDescriptor;
|
|
208
|
+
private creds: { accountId: string; apiToken: string } | null = null;
|
|
209
|
+
|
|
210
|
+
constructor(descriptor: CloudflareAlertsDescriptor) {
|
|
211
|
+
this.descriptor = CloudflareAlertsDescriptorSchema.parse(descriptor);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private async getCreds(): Promise<{ accountId: string; apiToken: string }> {
|
|
215
|
+
if (this.creds) return this.creds;
|
|
216
|
+
|
|
217
|
+
// Both credentials route through the `secrets/store` runtime kind
|
|
218
|
+
// (vibes secrets). Adapters declare key NAMES; the runtime picks
|
|
219
|
+
// the right backend by tier (encrypted-local for local, the CF
|
|
220
|
+
// secrets store for staging/production). process.env can override
|
|
221
|
+
// for CI / explicit shell sessions.
|
|
222
|
+
const env = this.descriptor.environment || 'local';
|
|
223
|
+
const ac = (this.descriptor.adapterConfig ?? {}) as Record<string, unknown>;
|
|
224
|
+
const accountKey = (typeof ac.accountIdEnvVar === 'string' ? ac.accountIdEnvVar : 'CLOUDFLARE_ACCOUNT_ID');
|
|
225
|
+
const primaryToken = (typeof ac.apiTokenEnvVar === 'string' ? ac.apiTokenEnvVar : 'CLOUDFLARE_NOTIFICATIONS_API_TOKEN');
|
|
226
|
+
const fallbackToken = (typeof ac.apiTokenFallbackEnvVar === 'string' ? ac.apiTokenFallbackEnvVar : 'CLOUDFLARE_API_TOKEN');
|
|
227
|
+
|
|
228
|
+
const accountRes = await resolveAdapterCredential({
|
|
229
|
+
keyCandidates: [accountKey],
|
|
230
|
+
environment: env,
|
|
231
|
+
humanName: 'Cloudflare account id'
|
|
232
|
+
});
|
|
233
|
+
const tokenRes = await resolveAdapterCredential({
|
|
234
|
+
keyCandidates: [primaryToken, fallbackToken],
|
|
235
|
+
environment: env,
|
|
236
|
+
humanName: 'Cloudflare API token (notifications scope preferred)'
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.creds = { accountId: accountRes.value, apiToken: tokenRes.value };
|
|
240
|
+
return this.creds;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async call<T>(path: string, init?: RequestInit): Promise<CfApiBody<T>> {
|
|
244
|
+
const { apiToken } = await this.getCreds();
|
|
245
|
+
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
246
|
+
...init,
|
|
247
|
+
headers: {
|
|
248
|
+
Authorization: `Bearer ${apiToken}`,
|
|
249
|
+
'Content-Type': 'application/json',
|
|
250
|
+
...(init?.headers ?? {})
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
const text = await res.text();
|
|
254
|
+
let body: CfApiBody<T>;
|
|
255
|
+
try {
|
|
256
|
+
body = JSON.parse(text) as CfApiBody<T>;
|
|
257
|
+
} catch {
|
|
258
|
+
throw new Error(`cloudflare-alerts: non-JSON response from ${path}: ${text.slice(0, 200)}`);
|
|
259
|
+
}
|
|
260
|
+
if (!res.ok || body.success === false) {
|
|
261
|
+
const errSummary = body.errors?.map((e) => `${e.code}: ${e.message}`).join('; ') ?? text.slice(0, 200);
|
|
262
|
+
throw new Error(`cloudflare-alerts: ${path} → ${res.status} ${errSummary}`);
|
|
263
|
+
}
|
|
264
|
+
return body;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async listPolicies(): Promise<Array<AlertPolicy & { providerId: string }>> {
|
|
268
|
+
const { accountId } = await this.getCreds();
|
|
269
|
+
const body = await this.call<CfPolicy[]>(`/accounts/${accountId}/alerting/v3/policies`);
|
|
270
|
+
return (body.result ?? []).map(fromCfPolicy);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async createPolicy(policy: AlertPolicy): Promise<{ providerId: string }> {
|
|
274
|
+
const { accountId } = await this.getCreds();
|
|
275
|
+
const payload = toCfPayload(policy);
|
|
276
|
+
const body = await this.call<{ id: string }>(
|
|
277
|
+
`/accounts/${accountId}/alerting/v3/policies`,
|
|
278
|
+
{ method: 'POST', body: JSON.stringify(payload) }
|
|
279
|
+
);
|
|
280
|
+
return { providerId: body.result.id };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async deletePolicy(providerId: string): Promise<void> {
|
|
284
|
+
const { accountId } = await this.getCreds();
|
|
285
|
+
await this.call(`/accounts/${accountId}/alerting/v3/policies/${providerId}`, {
|
|
286
|
+
method: 'DELETE'
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async diff(desired: AlertPolicy[]): Promise<{
|
|
291
|
+
create: AlertPolicy[];
|
|
292
|
+
update: Array<{ providerId: string; policy: AlertPolicy }>;
|
|
293
|
+
delete: Array<{ providerId: string; name: string }>;
|
|
294
|
+
}> {
|
|
295
|
+
const live = await this.listPolicies();
|
|
296
|
+
const liveByName = new Map(live.map((p) => [p.name, p]));
|
|
297
|
+
const create: AlertPolicy[] = [];
|
|
298
|
+
const update: Array<{ providerId: string; policy: AlertPolicy }> = [];
|
|
299
|
+
|
|
300
|
+
for (const policy of desired) {
|
|
301
|
+
const match = liveByName.get(policy.name);
|
|
302
|
+
if (!match) {
|
|
303
|
+
create.push(policy);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
liveByName.delete(policy.name);
|
|
307
|
+
// Coarse-grained diff: re-create if threshold or mechanisms changed.
|
|
308
|
+
const changed =
|
|
309
|
+
match.threshold !== policy.threshold ||
|
|
310
|
+
match.type !== policy.type ||
|
|
311
|
+
match.enabled !== policy.enabled ||
|
|
312
|
+
JSON.stringify(match.mechanisms) !== JSON.stringify(policy.mechanisms) ||
|
|
313
|
+
JSON.stringify(match.filters) !== JSON.stringify(policy.filters);
|
|
314
|
+
if (changed) {
|
|
315
|
+
update.push({ providerId: match.providerId, policy });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const deleteList = Array.from(liveByName.values()).map((p) => ({
|
|
320
|
+
providerId: p.providerId,
|
|
321
|
+
name: p.name
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
return { create, update, delete: deleteList };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function createCloudflareAlertsImplementation(
|
|
329
|
+
input: CloudflareAlertsDescriptorInput
|
|
330
|
+
): AlertsImplementation {
|
|
331
|
+
return new CloudflareAlertsImplementation(CloudflareAlertsDescriptorSchema.parse(input));
|
|
332
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare KV Implementation
|
|
3
|
+
*
|
|
4
|
+
* Config generator for `infra/cache` kind when engine is `cloudflare-kv`.
|
|
5
|
+
* Produces KV namespace binding config to merge into a wrangler config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as z from 'zod/v4';
|
|
9
|
+
|
|
10
|
+
export const CloudflareKVDescriptorSchema = z.object({
|
|
11
|
+
kind: z.literal('infra/cache'),
|
|
12
|
+
id: z.string().min(1),
|
|
13
|
+
name: z.string().min(1),
|
|
14
|
+
engine: z.literal('cloudflare-kv'),
|
|
15
|
+
/** Binding name (e.g. "SESSION_CACHE") */
|
|
16
|
+
binding: z.string().regex(/^[_A-Z0-9]+$/),
|
|
17
|
+
/** KV namespace ID (from Cloudflare dashboard or wrangler) */
|
|
18
|
+
namespaceId: z.string().min(1),
|
|
19
|
+
/** Optional preview namespace ID for non-production */
|
|
20
|
+
previewNamespaceId: z.string().optional()
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type CloudflareKVDescriptor = z.infer<typeof CloudflareKVDescriptorSchema>;
|
|
24
|
+
|
|
25
|
+
export interface WranglerKVConfig {
|
|
26
|
+
kv_namespaces: Array<{
|
|
27
|
+
binding: string;
|
|
28
|
+
id: string;
|
|
29
|
+
preview_id?: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate KV namespace binding config to merge into a wrangler config.
|
|
35
|
+
*/
|
|
36
|
+
export function generateCloudflareKVConfig(descriptor: CloudflareKVDescriptor): WranglerKVConfig {
|
|
37
|
+
const parsed = CloudflareKVDescriptorSchema.parse(descriptor);
|
|
38
|
+
|
|
39
|
+
const namespace: WranglerKVConfig['kv_namespaces'][number] = {
|
|
40
|
+
binding: parsed.binding,
|
|
41
|
+
id: parsed.namespaceId
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (parsed.previewNamespaceId) {
|
|
45
|
+
namespace.preview_id = parsed.previewNamespaceId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
kv_namespaces: [namespace]
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Cloudflare Logs descriptor — registered at plugin load time so
|
|
3
|
+
* `runtime.assets('infra/logs').descriptors()` returns at least one entry
|
|
4
|
+
* the moment the infra-cloudflare plugin is loaded.
|
|
5
|
+
*
|
|
6
|
+
* The descriptor only carries adapter identity + credential pointers. The
|
|
7
|
+
* concrete `sources` list (which workers to observe) is *not* declared
|
|
8
|
+
* here — that's app inventory, owned by each consumer's
|
|
9
|
+
* `deployment.config.ts`. CLI surfaces (e.g. `vibes infra logs`) discover
|
|
10
|
+
* sources from the workspace and merge them at display time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { InfraLogsDescriptor } from '@vibesdotdev/infra-core/kinds';
|
|
14
|
+
|
|
15
|
+
const descriptor: InfraLogsDescriptor = {
|
|
16
|
+
kind: 'infra/logs',
|
|
17
|
+
id: 'cloudflare-logs',
|
|
18
|
+
name: 'Cloudflare Logs',
|
|
19
|
+
description: 'Cloudflare Workers, Pages, D1, R2, and KV logs',
|
|
20
|
+
adapter: 'cloudflare-logs',
|
|
21
|
+
adapterConfig: {
|
|
22
|
+
accountId: { source: 'secret', key: 'CLOUDFLARE_ACCOUNT_ID' },
|
|
23
|
+
apiToken: { source: 'secret', key: 'CLOUDFLARE_API_TOKEN' }
|
|
24
|
+
},
|
|
25
|
+
sources: [],
|
|
26
|
+
alerts: []
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default descriptor;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Logs Implementation
|
|
3
|
+
*
|
|
4
|
+
* Implements the `infra/logs` kind for Cloudflare Workers.
|
|
5
|
+
* Provides programmatic log tailing, queries, and analytics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getVibesRuntime } from '@vibesdotdev/runtime';
|
|
9
|
+
import { CloudflareLogsConnector } from '@vibesdotdev/connector-cloudflare';
|
|
10
|
+
import { resolveCurrentEnvironmentConfig } from '@vibesdotdev/config/environment/current';
|
|
11
|
+
import type { InfraLogsDescriptor } from '@vibesdotdev/infra-core/kinds';
|
|
12
|
+
import type { SecretsStoreImplementation } from '@vibesdotdev/secrets/kinds';
|
|
13
|
+
import * as z from 'zod/v4';
|
|
14
|
+
|
|
15
|
+
export const CloudflareLogsDescriptorSchema = z.object({
|
|
16
|
+
kind: z.literal('infra/logs'),
|
|
17
|
+
id: z.string().min(1),
|
|
18
|
+
name: z.string().min(1),
|
|
19
|
+
description: z.string().optional(),
|
|
20
|
+
adapter: z.literal('cloudflare-logs'),
|
|
21
|
+
adapterConfig: z.record(z.string(), z.unknown()).optional(),
|
|
22
|
+
// Support top-level for backward compat with old descriptors; prefer adapterConfig
|
|
23
|
+
accountId: z.union([
|
|
24
|
+
z.string().min(1),
|
|
25
|
+
z.object({ source: z.literal('secret'), key: z.string().min(1) }),
|
|
26
|
+
z.object({ source: z.literal('env'), key: z.string().min(1) })
|
|
27
|
+
]).optional(),
|
|
28
|
+
apiToken: z.union([
|
|
29
|
+
z.object({ source: z.literal('env'), key: z.string().min(1) }),
|
|
30
|
+
z.object({ source: z.literal('secret'), key: z.string().min(1) })
|
|
31
|
+
]).optional(),
|
|
32
|
+
/**
|
|
33
|
+
* Optional legacy Global API Key + email pair. Provide both to fall back
|
|
34
|
+
* to `X-Auth-Key` + `X-Auth-Email` for endpoints that 405 on Bearer auth.
|
|
35
|
+
*/
|
|
36
|
+
apiKey: z.union([
|
|
37
|
+
z.object({ source: z.literal('env'), key: z.string().min(1) }),
|
|
38
|
+
z.object({ source: z.literal('secret'), key: z.string().min(1) })
|
|
39
|
+
]).optional(),
|
|
40
|
+
email: z.union([
|
|
41
|
+
z.object({ source: z.literal('env'), key: z.string().min(1) }),
|
|
42
|
+
z.object({ source: z.literal('secret'), key: z.string().min(1) })
|
|
43
|
+
]).optional(),
|
|
44
|
+
/**
|
|
45
|
+
* Optional override for the secrets-resolution environment scope. When
|
|
46
|
+
* unset, the impl resolves the current active environment from the
|
|
47
|
+
* config manifest (the same selection `vibes secrets list` honors),
|
|
48
|
+
* falling back to `'local'`.
|
|
49
|
+
*/
|
|
50
|
+
environment: z.string().optional(),
|
|
51
|
+
/** Log sources to monitor - matches infra/logs kind schema */
|
|
52
|
+
sources: z
|
|
53
|
+
.array(
|
|
54
|
+
z.object({
|
|
55
|
+
type: z.enum(['worker', 'pages', 'd1', 'r2', 'kv']),
|
|
56
|
+
name: z.string().min(1),
|
|
57
|
+
environment: z.string().optional()
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
.default([]),
|
|
61
|
+
retention: z
|
|
62
|
+
.object({
|
|
63
|
+
hotDays: z.number().int().positive().default(7),
|
|
64
|
+
coldDays: z.number().int().positive().default(30)
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
67
|
+
alerts: z
|
|
68
|
+
.array(
|
|
69
|
+
z.object({
|
|
70
|
+
name: z.string().min(1),
|
|
71
|
+
condition: z.enum(['errorRate', 'duration', 'invocationCount']),
|
|
72
|
+
threshold: z.number(),
|
|
73
|
+
window: z.string(),
|
|
74
|
+
channels: z.array(z.string()).default([])
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
.default([])
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export type CloudflareLogsDescriptorInput = z.input<typeof CloudflareLogsDescriptorSchema>;
|
|
81
|
+
export type CloudflareLogsDescriptor = z.infer<typeof CloudflareLogsDescriptorSchema>;
|
|
82
|
+
|
|
83
|
+
export interface LogsImplementation {
|
|
84
|
+
/** Stream logs in real-time (returns WebSocket URL) */
|
|
85
|
+
tailLogs(sourceName: string, options?: { status?: 'ok' | 'error' | 'canceled'; limit?: number }): Promise<{ tailId: string; websocketUrl: string }>;
|
|
86
|
+
/** Stop a log tail session */
|
|
87
|
+
stopTail(sourceName: string, tailId: string): Promise<void>;
|
|
88
|
+
/** List active tail sessions */
|
|
89
|
+
listTails(sourceName: string): Promise<Array<{ id: string; createdAt: string }>>;
|
|
90
|
+
/** Get analytics summary */
|
|
91
|
+
getAnalytics(sourceName: string, timeRange?: { timeStart: string; timeEnd?: string }): Promise<{ totalInvocations: number; errorCount: number; errorRate: number; avgDurationMs: number; p99DurationMs: number; totalCpuTimeMs: number }>;
|
|
92
|
+
/** Get error rate */
|
|
93
|
+
getErrorRate(sourceName: string, timeRange?: { timeStart: string; timeEnd?: string }): Promise<number>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class CloudflareLogsImplementation implements LogsImplementation {
|
|
97
|
+
readonly id = 'cloudflare-logs';
|
|
98
|
+
readonly descriptor: CloudflareLogsDescriptor;
|
|
99
|
+
private connector: CloudflareLogsConnector | null = null;
|
|
100
|
+
|
|
101
|
+
constructor(descriptor: CloudflareLogsDescriptor) {
|
|
102
|
+
this.descriptor = CloudflareLogsDescriptorSchema.parse(descriptor);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async getConnector(): Promise<CloudflareLogsConnector> {
|
|
106
|
+
if (this.connector) return this.connector;
|
|
107
|
+
|
|
108
|
+
const runtime = getVibesRuntime();
|
|
109
|
+
const secretsStore = await runtime.query('secrets/store').resolve() as SecretsStoreImplementation;
|
|
110
|
+
|
|
111
|
+
// Prefer adapterConfig for constitution-aligned config (adapter-specific under adapterConfig)
|
|
112
|
+
const ac = (this.descriptor.adapterConfig || {}) as Record<string, unknown>;
|
|
113
|
+
const accountId = (this.descriptor.accountId ?? ac.accountId ?? { source: 'secret', key: 'CLOUDFLARE_ACCOUNT_ID' }) as any;
|
|
114
|
+
const apiToken = (this.descriptor.apiToken ?? ac.apiToken ?? { source: 'secret', key: 'CLOUDFLARE_API_TOKEN' }) as any;
|
|
115
|
+
// Legacy auth pair is optional — only used by endpoints that 405 on Bearer.
|
|
116
|
+
const apiKey = (this.descriptor.apiKey ?? ac.apiKey) as any;
|
|
117
|
+
const email = (this.descriptor.email ?? ac.email) as any;
|
|
118
|
+
|
|
119
|
+
// Resolve which environment scope to read secrets from. Descriptor
|
|
120
|
+
// override wins; otherwise follow the user's active environment
|
|
121
|
+
// selection (same source `vibes secrets list` reads). Final fallback
|
|
122
|
+
// is `'local'` so the CLI stays usable when the config manifest
|
|
123
|
+
// hasn't been initialized.
|
|
124
|
+
let environment = this.descriptor.environment;
|
|
125
|
+
if (!environment) {
|
|
126
|
+
try {
|
|
127
|
+
const current = await resolveCurrentEnvironmentConfig();
|
|
128
|
+
environment = current.name;
|
|
129
|
+
} catch {
|
|
130
|
+
environment = 'local';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.connector = await CloudflareLogsConnector.create({
|
|
135
|
+
accountId,
|
|
136
|
+
apiToken,
|
|
137
|
+
...(apiKey ? { apiKey } : {}),
|
|
138
|
+
...(email ? { email } : {})
|
|
139
|
+
}, {
|
|
140
|
+
environment,
|
|
141
|
+
secretsStore
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return this.connector;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async tailLogs(
|
|
148
|
+
workerName: string,
|
|
149
|
+
options: { status?: 'ok' | 'error' | 'canceled'; method?: string; header?: string; limit?: number } = {}
|
|
150
|
+
): Promise<{ tailId: string; websocketUrl: string }> {
|
|
151
|
+
const connector = await this.getConnector();
|
|
152
|
+
try {
|
|
153
|
+
return await connector.tailLogs(workerName, options);
|
|
154
|
+
} catch (error: unknown) {
|
|
155
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
156
|
+
if (msg.includes('Method not allowed for this authentication scheme')) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'Cloudflare rejected log tail with "method not allowed for this authentication scheme". ' +
|
|
159
|
+
'Your API token likely lacks the "Workers Scripts:Edit" (or equivalent Tail) permission. ' +
|
|
160
|
+
'Either grant that permission to CLOUDFLARE_API_TOKEN, or configure ' +
|
|
161
|
+
'CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL in the secrets store for legacy auth fallback.'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async stopTail(sourceName: string, tailId: string): Promise<void> {
|
|
169
|
+
const connector = await this.getConnector();
|
|
170
|
+
await connector.stopTail(sourceName, tailId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async listTails(sourceName: string): Promise<Array<{ id: string; createdAt: string }>> {
|
|
174
|
+
const connector = await this.getConnector();
|
|
175
|
+
const sessions = await connector.listTails(sourceName);
|
|
176
|
+
return sessions.map((s) => ({ id: s.tailId, createdAt: s.createdAt.toISOString() }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getAnalytics(
|
|
180
|
+
sourceName: string,
|
|
181
|
+
timeRange?: { timeStart: string; timeEnd?: string }
|
|
182
|
+
): Promise<{ totalInvocations: number; errorCount: number; errorRate: number; avgDurationMs: number; p99DurationMs: number; totalCpuTimeMs: number }> {
|
|
183
|
+
const connector = await this.getConnector();
|
|
184
|
+
return connector.getAnalytics(sourceName, timeRange ?? {});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getErrorRate(
|
|
188
|
+
sourceName: string,
|
|
189
|
+
timeRange?: { timeStart: string; timeEnd?: string }
|
|
190
|
+
): Promise<number> {
|
|
191
|
+
const connector = await this.getConnector();
|
|
192
|
+
return connector.getErrorRate(sourceName, timeRange ?? {});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createCloudflareLogsImplementation(
|
|
197
|
+
input: CloudflareLogsDescriptorInput
|
|
198
|
+
): LogsImplementation {
|
|
199
|
+
const descriptor = CloudflareLogsDescriptorSchema.parse(input);
|
|
200
|
+
return new CloudflareLogsImplementation(descriptor);
|
|
201
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Cloudflare Workers Observability descriptor — auto-registered
|
|
3
|
+
* at plugin load so `runtime.assets('infra/observability').descriptors()`
|
|
4
|
+
* surfaces it without each consumer declaring one in their config.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { InfraObservabilityDescriptor } from '@vibesdotdev/infra-core';
|
|
8
|
+
|
|
9
|
+
const descriptor: InfraObservabilityDescriptor = {
|
|
10
|
+
kind: 'infra/observability',
|
|
11
|
+
id: 'cloudflare-workers-observability',
|
|
12
|
+
name: 'Cloudflare Workers Observability',
|
|
13
|
+
description: 'Per-Worker observability config (logs/traces sampling, exports) via CF Workers Settings API',
|
|
14
|
+
adapter: 'cloudflare-workers-observability',
|
|
15
|
+
adapterConfig: {
|
|
16
|
+
apiTokenEnvVar: 'CLOUDFLARE_API_TOKEN',
|
|
17
|
+
accountIdEnvVar: 'CLOUDFLARE_ACCOUNT_ID'
|
|
18
|
+
},
|
|
19
|
+
environment: 'local',
|
|
20
|
+
// Default scope: every Worker in the account. CLI commands can
|
|
21
|
+
// narrow with --worker.
|
|
22
|
+
defaultScope: { kind: 'account-wide' }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default descriptor;
|