breakaway 2.0.0-main.83 → 2.0.0-main.85
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/infra-cloudflare.js +91 -16
- package/src/infra-provider.js +8 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.85",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "breakaway",
|
|
9
|
-
"version": "2.0.0-main.
|
|
9
|
+
"version": "2.0.0-main.85",
|
|
10
10
|
"license": "FSL-1.1-Apache-2.0",
|
|
11
11
|
"bin": {
|
|
12
12
|
"breakaway": "scripts/tasks.mjs"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "2.0.0-main.
|
|
3
|
+
"version": "2.0.0-main.85",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/infra-cloudflare.js
CHANGED
|
@@ -1162,38 +1162,95 @@ export async function observe(ctx) {
|
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
1164
|
/**
|
|
1165
|
-
* A Cloudflare alert, cut down to
|
|
1166
|
-
* (its text, data, account and policy IDs) is left out. The board's alert webhook
|
|
1167
|
-
* alert history (`events`) both read alerts with this, so an alert heard both ways
|
|
1165
|
+
* A Cloudflare alert, cut down to what places it: its name, when it fired, and the Worker, zone, or hostname it names.
|
|
1166
|
+
* Everything else (its text, the rest of its data, account and policy IDs) is left out. The board's alert webhook
|
|
1167
|
+
* (src/store-routines.js) and the alert history (`events`) both read alerts with this, so an alert heard both ways
|
|
1168
|
+
* reads the same. `zoneId` is only for finding the zone's name; it's never stored.
|
|
1168
1169
|
* @param {any} body a notification webhook's body, or an alert history entry's `alert_body`
|
|
1169
|
-
* @returns {{ alert: string | null, at: string | null, worker: string | null
|
|
1170
|
+
* @returns {{ alert: string | null, at: string | null, worker: string | null, zone: string | null,
|
|
1171
|
+
* zoneId: string | null, hostname: string | null }}
|
|
1170
1172
|
*/
|
|
1171
1173
|
export function alertFields(body) {
|
|
1172
|
-
const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim()) ?? null;
|
|
1174
|
+
const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim())?.trim() ?? null;
|
|
1175
|
+
const first = (v) => (Array.isArray(v) ? v.find((x) => typeof x === 'string') : v);
|
|
1173
1176
|
const data = body?.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data : {};
|
|
1174
1177
|
const ts = Number(body?.ts);
|
|
1175
1178
|
const at =
|
|
1176
1179
|
Number.isFinite(ts) && ts > 0
|
|
1177
1180
|
? new Date(ts < 1e11 ? ts * 1000 : ts)
|
|
1178
1181
|
: new Date(pick(body?.timestamp, body?.time) ?? Number.NaN);
|
|
1182
|
+
const host = pick(data.hostname, data.host, data.domain, first(data.hostnames), first(data.hosts));
|
|
1179
1183
|
return {
|
|
1180
1184
|
alert: pick(body?.alert_name, body?.policy_name, body?.name, body?.alert_type),
|
|
1181
1185
|
at: Number.isNaN(at.getTime()) ? null : at.toISOString(),
|
|
1182
1186
|
worker: pick(data.script_name, data.worker_name, data.worker, data.service, data.script),
|
|
1187
|
+
zone: pick(data.zone_name, data.zone)?.toLowerCase() ?? null,
|
|
1188
|
+
zoneId: pick(data.zone_tag, data.zone_id),
|
|
1189
|
+
hostname: host ? host.toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '') : null,
|
|
1183
1190
|
};
|
|
1184
1191
|
}
|
|
1185
1192
|
|
|
1186
|
-
/** An alert signal's text: the alert's name and
|
|
1187
|
-
export function alertText(alert,
|
|
1193
|
+
/** An alert signal's text: the alert's name and what it's on (a Worker, a zone, or a hostname). */
|
|
1194
|
+
export function alertText(alert, on) {
|
|
1188
1195
|
const name = String(alert ?? '').slice(0, 200) || 'unnamed alert';
|
|
1189
|
-
return `Cloudflare alert: ${name}${
|
|
1196
|
+
return `Cloudflare alert: ${name}${on ? ` on ${String(on).slice(0, 100)}` : ''}`;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** The zones an environment's resources use: the zone of each of its routes and custom domains. */
|
|
1200
|
+
export function zonesOf(resources) {
|
|
1201
|
+
const zones = new Set();
|
|
1202
|
+
for (const r of resources ?? []) {
|
|
1203
|
+
if (r.kind !== 'route' && r.kind !== 'custom-domain') continue;
|
|
1204
|
+
const zone = /** @type {any} */ (r.attrs)?.zone;
|
|
1205
|
+
if (typeof zone === 'string' && zone) zones.add(zone.toLowerCase());
|
|
1206
|
+
}
|
|
1207
|
+
return zones;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Where an alert goes in one environment (BRK-255), from its fields and the environment's resources: on the Worker it
|
|
1212
|
+
* names when that Worker is the environment's; on the whole environment when the zone it names (or its hostname's
|
|
1213
|
+
* zone) is one the environment's routes or custom domains use; `account` when it names no Worker, zone, or hostname
|
|
1214
|
+
* (it's about the account or the platform, and the board keeps one per provider, not one per environment); or null
|
|
1215
|
+
* when it's another environment's, or no environment's.
|
|
1216
|
+
*
|
|
1217
|
+
* With `elsewhere`, the resources of the provider's other environments, an alert naming a zone or hostname that
|
|
1218
|
+
* neither this environment nor any other uses is `account` too (BRK-256), still on that zone or hostname, rather than
|
|
1219
|
+
* nobody's. A zone named only by its ID, which nothing here can name, stays nobody's.
|
|
1220
|
+
* @param {ReturnType<typeof alertFields>} fields
|
|
1221
|
+
* @param {Array<{ kind: string, name: string, attrs?: Record<string, unknown> }>} resources
|
|
1222
|
+
* @param {Array<{ kind: string, name: string, attrs?: Record<string, unknown> }>} [elsewhere]
|
|
1223
|
+
* @returns {{ resource: string | null, account: boolean, on: string | null } | null}
|
|
1224
|
+
*/
|
|
1225
|
+
export function alertPlace(fields, resources, elsewhere) {
|
|
1226
|
+
if (fields.worker) {
|
|
1227
|
+
const mine = (resources ?? []).some((r) => r.kind === 'worker' && r.name === fields.worker);
|
|
1228
|
+
return mine ? { resource: rid('worker', fields.worker), account: false, on: fields.worker } : null;
|
|
1229
|
+
}
|
|
1230
|
+
if (!fields.zone && !fields.hostname && !fields.zoneId) return { resource: null, account: true, on: null };
|
|
1231
|
+
const on = fields.hostname ?? fields.zone;
|
|
1232
|
+
if (zoneUsed(fields, resources)) return { resource: null, account: false, on };
|
|
1233
|
+
if (elsewhere && on && !zoneUsed(fields, elsewhere)) return { resource: null, account: true, on };
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Whether the zone an alert names, or its hostname's zone, is one that `resources`' routes or custom domains use. */
|
|
1238
|
+
function zoneUsed(fields, resources) {
|
|
1239
|
+
const zones = zonesOf(resources);
|
|
1240
|
+
const host = fields.hostname;
|
|
1241
|
+
return Boolean(
|
|
1242
|
+
(fields.zone && zones.has(fields.zone)) || (host && [...zones].some((z) => host === z || host.endsWith(`.${z}`))),
|
|
1243
|
+
);
|
|
1190
1244
|
}
|
|
1191
1245
|
|
|
1192
1246
|
/**
|
|
1193
1247
|
* The account's alerts since `since`, from its alert history (`GET …/alerting/v3/history`, Notifications Read), as
|
|
1194
|
-
* `alert` signals:
|
|
1195
|
-
* when it names
|
|
1196
|
-
*
|
|
1248
|
+
* `alert` signals placed by `alertPlace` (BRK-255): on the Worker it names when that Worker is in the environment, on
|
|
1249
|
+
* the whole environment when it names a zone or hostname the environment uses, and marked `account` when it names
|
|
1250
|
+
* none of them, or (given `ctx.elsewhere`, BRK-256) a zone or hostname no environment uses; an alert about another
|
|
1251
|
+
* environment's Worker or zone is left out. A zone named only by its ID is looked up in the account's zones (Zone
|
|
1252
|
+
* Read). Oldest first. The board's alert webhook reports the same alerts as they fire; the store keeps one of each, and
|
|
1253
|
+
* one of each account-wide alert across the provider's environments.
|
|
1197
1254
|
* @param {ProviderContext} ctx
|
|
1198
1255
|
* @param {string} since ISO 8601
|
|
1199
1256
|
* @returns {Promise<import('./infra-provider.js').Signal[]>}
|
|
@@ -1204,11 +1261,25 @@ export async function events(ctx, since) {
|
|
|
1204
1261
|
const now = Date.now();
|
|
1205
1262
|
if (from > now) return [];
|
|
1206
1263
|
const resources = ctx.resources ?? (await discover(ctx)).resources;
|
|
1207
|
-
const workers = new Set(resources.filter((r) => r.kind === 'worker').map((r) => r.name));
|
|
1208
1264
|
const cf = reader(ctx);
|
|
1209
|
-
const
|
|
1265
|
+
const account = await accountOf(cf, ctx);
|
|
1266
|
+
const a = enc(account);
|
|
1210
1267
|
const window = `since=${enc(new Date(from).toISOString())}&before=${enc(new Date(now).toISOString())}`;
|
|
1211
1268
|
const history = await cf.all(`/accounts/${a}/alerting/v3/history?${window}`, { permission: 'Notifications Read' });
|
|
1269
|
+
/** @type {Map<string, string> | null} zone names by ID, read only when an alert names a zone by its ID alone */
|
|
1270
|
+
let zoneNames = null;
|
|
1271
|
+
const zoneName = async (id) => {
|
|
1272
|
+
if (!zoneNames) {
|
|
1273
|
+
zoneNames = new Map();
|
|
1274
|
+
try {
|
|
1275
|
+
for (const z of await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50))
|
|
1276
|
+
zoneNames.set(String(z.id), String(z.name).toLowerCase());
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
if (error?.status !== 403) throw error;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return zoneNames.get(id) ?? null;
|
|
1282
|
+
};
|
|
1212
1283
|
/** @type {import('./infra-provider.js').Signal[]} */
|
|
1213
1284
|
const signals = [];
|
|
1214
1285
|
for (const entry of history) {
|
|
@@ -1222,16 +1293,20 @@ export async function events(ctx, since) {
|
|
|
1222
1293
|
const fields = alertFields(body);
|
|
1223
1294
|
const at = fields.at ?? (Number.isNaN(Date.parse(entry?.sent)) ? null : new Date(entry.sent).toISOString());
|
|
1224
1295
|
if (!at || Date.parse(at) < from) continue;
|
|
1225
|
-
if (fields.worker && !
|
|
1296
|
+
if (!fields.worker && !fields.zone && !fields.hostname && fields.zoneId)
|
|
1297
|
+
fields.zone = await zoneName(fields.zoneId);
|
|
1298
|
+
const place = alertPlace(fields, resources, ctx.elsewhere);
|
|
1299
|
+
if (!place) continue;
|
|
1226
1300
|
signals.push({
|
|
1227
1301
|
source: 'cloudflare',
|
|
1228
1302
|
environment: ctx.environment,
|
|
1229
|
-
resource:
|
|
1303
|
+
resource: place.resource,
|
|
1230
1304
|
kind: 'alert',
|
|
1231
1305
|
level: 'warning',
|
|
1232
1306
|
value: null,
|
|
1233
1307
|
at,
|
|
1234
|
-
text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type,
|
|
1308
|
+
text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type, place.on),
|
|
1309
|
+
...(place.account ? { account: true } : {}),
|
|
1235
1310
|
});
|
|
1236
1311
|
}
|
|
1237
1312
|
return signals.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
|
package/src/infra-provider.js
CHANGED
|
@@ -123,6 +123,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
123
123
|
* @property {number | null} value
|
|
124
124
|
* @property {string} at ISO 8601
|
|
125
125
|
* @property {string} text short, at most SIGNAL_TEXT_MAX characters
|
|
126
|
+
* @property {boolean} [account] true for an alert about the provider's account or platform as a whole, not this
|
|
127
|
+
* environment (BRK-255): its resource is null, and the board keeps one per provider, not one per environment
|
|
126
128
|
*/
|
|
127
129
|
|
|
128
130
|
/**
|
|
@@ -141,6 +143,8 @@ export const COST_NOTE_MAX = 500;
|
|
|
141
143
|
* @property {typeof fetch} [fetch] the fetch to call the platform with; tests pass a mock
|
|
142
144
|
* @property {Resource[]} [resources] what `discover` just found in the environment's scope: the store passes it to
|
|
143
145
|
* `observe` and `cost` so they needn't discover again
|
|
146
|
+
* @property {Resource[]} [elsewhere] what `discover` just found in the provider's other environments: `events` keeps an
|
|
147
|
+
* alert on a zone or hostname none of them, nor this one, uses as account-wide instead of leaving it out (BRK-256)
|
|
144
148
|
* @property {Set<string>} [reached] the store's collector: the provider adds a permission's name when a call answers
|
|
145
149
|
* with it, so Connections puts back one an earlier refusal struck (BRK-254)
|
|
146
150
|
*/
|
|
@@ -495,6 +499,10 @@ export function checkSignals(provider, ctx, since, signals) {
|
|
|
495
499
|
if (s.source !== provider.id) fail(what, `a signal is from ${s.source}, not ${provider.id}`);
|
|
496
500
|
if (s.environment !== ctx.environment) fail(what, `a signal is for ${s.environment}, not ${ctx.environment}`);
|
|
497
501
|
if (s.resource !== null && !text(s.resource)) fail(what, 'a signal has a resource that is neither an id nor null');
|
|
502
|
+
if (s.account !== undefined && typeof s.account !== 'boolean')
|
|
503
|
+
fail(what, 'a signal has an account that isn’t true or false');
|
|
504
|
+
if (s.account && (s.resource !== null || s.kind !== 'alert'))
|
|
505
|
+
fail(what, 'an account signal is an alert on no resource');
|
|
498
506
|
if (!SIGNAL_KINDS.includes(s.kind)) fail(what, `a signal has unknown kind "${s.kind}"`);
|
|
499
507
|
if (!SIGNAL_LEVELS.includes(s.level)) fail(what, `a signal has unknown level "${s.level}"`);
|
|
500
508
|
if (s.value !== null && (typeof s.value !== 'number' || !Number.isFinite(s.value)))
|