create-qpq-app 0.1.18 → 0.1.19
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/package.json +2 -2
- package/template/apps/qpqjs/services/test/service/src/infrastructure.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeRun.ts +5 -3
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/SmokeTestDefinition.ts +7 -1
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/schedule/askRunScheduleTest.ts +88 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/schedule/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/smoke/tests/smokeTestRegistry.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/tick/config/defineTick.ts +50 -0
- package/template/apps/qpqjs/services/test/service/src/tick/config/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/tick/constants/index.ts +2 -0
- package/template/apps/qpqjs/services/test/service/src/tick/constants/scheduleTickName.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/tick/constants/scheduleTickStore.ts +3 -0
- package/template/apps/qpqjs/services/test/service/src/tick/entry/kvsStream/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/tick/entry/kvsStream/onScheduleTickStream.ts +41 -0
- package/template/apps/qpqjs/services/test/service/src/tick/entry/schedule/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/tick/entry/schedule/onTick.ts +45 -0
- package/template/apps/qpqjs/services/test/service/src/tick/index.ts +1 -0
- package/template/apps/qpqjs/services/test/service/src/tick/models/ScheduleTickRecord.ts +17 -0
- package/template/apps/qpqjs/services/test/service/src/tick/models/index.ts +1 -0
- package/template/docusaurus/docs/config/core/recurring-schedule.md +31 -28
- package/template/scripts/deployedSmoke.mjs +7 -80
- package/template/scripts/localSmoke.mjs +188 -0
- package/template/scripts/smoke/runSmokeRun.mjs +100 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-qpq-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Scaffold a new quidproquo app: npx create-qpq-app my-app",
|
|
5
5
|
"main": "./lib/commonjs/index.js",
|
|
6
6
|
"module": "./lib/esm/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^22.13.13",
|
|
58
|
-
"quidproquo-tsconfig": "0.1.
|
|
58
|
+
"quidproquo-tsconfig": "0.1.19"
|
|
59
59
|
},
|
|
60
60
|
"bin": {
|
|
61
61
|
"create-qpq-app": "./lib/commonjs/bin/createQpqApp.js"
|
|
@@ -4,6 +4,7 @@ import { QpqjsServiceEnum } from '@qpqjs/constants';
|
|
|
4
4
|
import { defineQpqjsService } from '@qpqjs/service-utils';
|
|
5
5
|
|
|
6
6
|
import { defineSmoke } from './smoke/config/defineSmoke';
|
|
7
|
+
import { defineTick } from './tick/config/defineTick';
|
|
7
8
|
|
|
8
9
|
export default [
|
|
9
10
|
defineDevServerOptions({ port: 3083 }),
|
|
@@ -16,4 +17,6 @@ export default [
|
|
|
16
17
|
),
|
|
17
18
|
|
|
18
19
|
defineSmoke(),
|
|
20
|
+
|
|
21
|
+
defineTick(),
|
|
19
22
|
];
|
package/template/apps/qpqjs/services/test/service/src/smoke/logic/smokeRun/askExecuteSmokeRun.ts
CHANGED
|
@@ -21,10 +21,11 @@ import { smokeTestRegistry } from '../../tests/smokeTestRegistry';
|
|
|
21
21
|
// Runs one registered test, returning its completed result entry.
|
|
22
22
|
function* askRunSmokeTest(
|
|
23
23
|
test: SmokeTestDefinition,
|
|
24
|
-
pending: SmokeTestResult
|
|
24
|
+
pending: SmokeTestResult,
|
|
25
|
+
runId: string
|
|
25
26
|
): AskResponse<SmokeTestResult> {
|
|
26
27
|
const startedAt = yield* askDateNow();
|
|
27
|
-
const outcome = yield* askCatch(test.askRun());
|
|
28
|
+
const outcome = yield* askCatch(test.askRun(runId));
|
|
28
29
|
const finishedAt = yield* askDateNow();
|
|
29
30
|
|
|
30
31
|
return {
|
|
@@ -53,7 +54,8 @@ export function* askExecuteSmokeRun(runId: string): AskResponse<SmokeRun> {
|
|
|
53
54
|
for (let index = 0; index < smokeTestRegistry.length; index += 1) {
|
|
54
55
|
const result = yield* askRunSmokeTest(
|
|
55
56
|
smokeTestRegistry[index],
|
|
56
|
-
smokeRun.tests[index]
|
|
57
|
+
smokeRun.tests[index],
|
|
58
|
+
runId
|
|
57
59
|
);
|
|
58
60
|
|
|
59
61
|
smokeRun = {
|
|
@@ -2,7 +2,13 @@ import { AskResponse } from 'quidproquo';
|
|
|
2
2
|
|
|
3
3
|
// A registered smoke test. `askRun` passes by returning and fails by throwing
|
|
4
4
|
// (askThrowError); the runner catches and records the error text as the message.
|
|
5
|
+
//
|
|
6
|
+
// The run id is handed to every test, and most ignore it: a test that leaves a
|
|
7
|
+
// mark somewhere durable stamps it, so what it later reads back is provably
|
|
8
|
+
// its own rather than the previous run's. Declaring the parameter costs the
|
|
9
|
+
// tests that do not want it nothing, since a zero-argument function still
|
|
10
|
+
// satisfies this.
|
|
5
11
|
export type SmokeTestDefinition = {
|
|
6
12
|
name: string;
|
|
7
|
-
askRun: () => AskResponse<void>;
|
|
13
|
+
askRun: (runId: string) => AskResponse<void>;
|
|
8
14
|
};
|
package/template/apps/qpqjs/services/test/service/src/smoke/tests/schedule/askRunScheduleTest.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askDateNow,
|
|
3
|
+
askDelay,
|
|
4
|
+
askKeyValueStoreGet,
|
|
5
|
+
askKeyValueStoreUpsert,
|
|
6
|
+
AskResponse,
|
|
7
|
+
Nullable,
|
|
8
|
+
} from 'quidproquo';
|
|
9
|
+
|
|
10
|
+
import { SCHEDULE_TICK_NAME } from '../../../tick/constants/scheduleTickName';
|
|
11
|
+
import { SCHEDULE_TICK_STORE } from '../../../tick/constants/scheduleTickStore';
|
|
12
|
+
import { ScheduleTickRecord } from '../../../tick/models/ScheduleTickRecord';
|
|
13
|
+
import { askSmokeAssert } from '../askSmokeAssert';
|
|
14
|
+
|
|
15
|
+
// The schedule runs every minute, so the wait has to cover a full boundary
|
|
16
|
+
// plus the stream hop after it. Generous rather than tight: the first fire
|
|
17
|
+
// after a deploy is the slowest one, and a flake here blocks a deploy.
|
|
18
|
+
const POLL_ATTEMPTS = 40;
|
|
19
|
+
const POLL_INTERVAL_MS = 3000;
|
|
20
|
+
|
|
21
|
+
// Which link stalled, from the state the chain left behind. Worth the few
|
|
22
|
+
// lines: "the schedule never fired" and "the stream never fired" send you to
|
|
23
|
+
// completely different places, and a bare timeout says neither.
|
|
24
|
+
const describeStall = (
|
|
25
|
+
tick: Nullable<ScheduleTickRecord>,
|
|
26
|
+
runId: string
|
|
27
|
+
): string => {
|
|
28
|
+
if (!tick) {
|
|
29
|
+
return 'the seeded tick row is gone, so something else deleted it';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (tick.runId !== runId) {
|
|
33
|
+
return `the tick row now belongs to run [${tick.runId}], so a concurrent smoke run overwrote it`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!tick.processedAt) {
|
|
37
|
+
return 'the schedule never fired: the row was never marked processed';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `the schedule fired at ${tick.processedAt} but the store's stream never acknowledged it`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Proves a recurring schedule fires, and that a key-value store's change
|
|
45
|
+
* stream delivers, in one wait.
|
|
46
|
+
*
|
|
47
|
+
* Seeds one row stamped with this run, then waits for that same row to come
|
|
48
|
+
* back acknowledged - which only happens if the schedule picked it up and
|
|
49
|
+
* stamped processedAt, and the store's stream then saw that change and stamped
|
|
50
|
+
* acknowledgedAt. Deployed that is an EventBridge rule and a DynamoDB stream;
|
|
51
|
+
* locally it is the dev server's ticker and its stream implementation. Same
|
|
52
|
+
* assertion either way.
|
|
53
|
+
*
|
|
54
|
+
* Matching on the run id is what makes this unambiguous without deleting
|
|
55
|
+
* anything first: a stale row is one that does not match, so there is never a
|
|
56
|
+
* window where the record is missing, and never a question of whether what was
|
|
57
|
+
* read is recent enough to count.
|
|
58
|
+
*
|
|
59
|
+
* Slow by nature - up to a minute of real time, because that is how long a
|
|
60
|
+
* per-minute schedule can take to come round.
|
|
61
|
+
*/
|
|
62
|
+
export function* askRunScheduleTest(runId: string): AskResponse<void> {
|
|
63
|
+
yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
|
|
64
|
+
scheduleName: SCHEDULE_TICK_NAME,
|
|
65
|
+
runId,
|
|
66
|
+
requestedAt: yield* askDateNow(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let tick: Nullable<ScheduleTickRecord> = null;
|
|
70
|
+
|
|
71
|
+
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
|
|
72
|
+
yield* askDelay(POLL_INTERVAL_MS);
|
|
73
|
+
|
|
74
|
+
tick = yield* askKeyValueStoreGet<ScheduleTickRecord>(
|
|
75
|
+
SCHEDULE_TICK_STORE,
|
|
76
|
+
SCHEDULE_TICK_NAME
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (tick?.runId === runId && tick.acknowledgedAt) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
yield* askSmokeAssert(
|
|
85
|
+
false,
|
|
86
|
+
`no acknowledgement within ${(POLL_ATTEMPTS * POLL_INTERVAL_MS) / 1000}s: ${describeStall(tick, runId)}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './askRunScheduleTest';
|
|
@@ -4,6 +4,7 @@ import { askRunEventBusTest } from './eventBus/askRunEventBusTest';
|
|
|
4
4
|
import { askRunKeyValueStoreTest } from './keyValueStore/askRunKeyValueStoreTest';
|
|
5
5
|
import { askRunNoopTest } from './noop/askRunNoopTest';
|
|
6
6
|
import { askRunParameterTest } from './parameter/askRunParameterTest';
|
|
7
|
+
import { askRunScheduleTest } from './schedule/askRunScheduleTest';
|
|
7
8
|
import { askRunSecretTest } from './secret/askRunSecretTest';
|
|
8
9
|
import { askRunStorageDriveTest } from './storageDrive/askRunStorageDriveTest';
|
|
9
10
|
import { SmokeTestDefinition } from './SmokeTestDefinition';
|
|
@@ -26,4 +27,5 @@ export const smokeTestRegistry: SmokeTestDefinition[] = [
|
|
|
26
27
|
name: 'crossServiceStorageDrive',
|
|
27
28
|
askRun: askRunCrossServiceStorageDriveTest,
|
|
28
29
|
},
|
|
30
|
+
{ name: 'schedule', askRun: askRunScheduleTest },
|
|
29
31
|
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineKeyValueStore,
|
|
3
|
+
defineRecurringSchedule,
|
|
4
|
+
QPQConfig,
|
|
5
|
+
} from 'quidproquo';
|
|
6
|
+
|
|
7
|
+
import { SCHEDULE_TICK_STORE } from '../constants/scheduleTickStore';
|
|
8
|
+
import { ScheduleTickRecord } from '../models/ScheduleTickRecord';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A heartbeat schedule, and the one row that lets a test prove it fired.
|
|
12
|
+
*
|
|
13
|
+
* The chain the smoke suite's schedule test drives, all on a single record:
|
|
14
|
+
*
|
|
15
|
+
* test seeds it -> the schedule stamps processedAt
|
|
16
|
+
* -> the store's stream stamps acknowledgedAt
|
|
17
|
+
* -> the test sees acknowledgedAt for its own run
|
|
18
|
+
*
|
|
19
|
+
* One wait covers three things that are otherwise untested together: that a
|
|
20
|
+
* declared schedule fires at all, that it fires the same way locally as
|
|
21
|
+
* deployed, and that a key-value store's change stream delivers.
|
|
22
|
+
*
|
|
23
|
+
* Every minute, which is the fastest a schedule can go and the only cadence a
|
|
24
|
+
* test can reasonably wait out.
|
|
25
|
+
*/
|
|
26
|
+
export const defineTick = (): QPQConfig => [
|
|
27
|
+
defineKeyValueStore<ScheduleTickRecord>(
|
|
28
|
+
SCHEDULE_TICK_STORE,
|
|
29
|
+
'scheduleName',
|
|
30
|
+
[],
|
|
31
|
+
{
|
|
32
|
+
onStream: {
|
|
33
|
+
runtime: {
|
|
34
|
+
basePath: __dirname,
|
|
35
|
+
relativePath: '../entry/kvsStream/onScheduleTickStream',
|
|
36
|
+
functionName: 'onScheduleTickStream',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
),
|
|
41
|
+
|
|
42
|
+
defineRecurringSchedule(
|
|
43
|
+
{ everyMinutes: 1 },
|
|
44
|
+
{
|
|
45
|
+
basePath: __dirname,
|
|
46
|
+
relativePath: '../entry/schedule/onTick',
|
|
47
|
+
functionName: 'onTick',
|
|
48
|
+
}
|
|
49
|
+
),
|
|
50
|
+
];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './defineTick';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './onScheduleTickStream';
|
package/template/apps/qpqjs/services/test/service/src/tick/entry/kvsStream/onScheduleTickStream.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askDateNow,
|
|
3
|
+
askKeyValueStoreUpsert,
|
|
4
|
+
AskResponse,
|
|
5
|
+
KvsStreamEventResponse,
|
|
6
|
+
KvsStreamRecord,
|
|
7
|
+
} from 'quidproquo';
|
|
8
|
+
|
|
9
|
+
import { SCHEDULE_TICK_STORE } from '../../constants/scheduleTickStore';
|
|
10
|
+
import { ScheduleTickRecord } from '../../models/ScheduleTickRecord';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stamps acknowledgedAt on a tick row the schedule has marked processed.
|
|
14
|
+
*
|
|
15
|
+
* The last link in the smoke chain: the test seeds a row, the schedule marks
|
|
16
|
+
* it, and this proves the store's change stream delivered that change. Nothing
|
|
17
|
+
* else in the suite exercises a stream, so a stream that stopped firing would
|
|
18
|
+
* otherwise go unnoticed.
|
|
19
|
+
*
|
|
20
|
+
* It writes back into the store it streams from, which is the shape that
|
|
21
|
+
* usually means an infinite loop. It terminates here because the guard is
|
|
22
|
+
* self-limiting: the only write it makes is the one that sets acknowledgedAt,
|
|
23
|
+
* and a row with acknowledgedAt already set falls out at the first line. So
|
|
24
|
+
* one processed row costs exactly one extra no-op delivery, and testing that
|
|
25
|
+
* no-op is part of the point - a handler writing to its own table is ordinary,
|
|
26
|
+
* and getting the guard wrong is the interesting failure.
|
|
27
|
+
*/
|
|
28
|
+
export function* onScheduleTickStream(
|
|
29
|
+
record: KvsStreamRecord<ScheduleTickRecord>
|
|
30
|
+
): AskResponse<KvsStreamEventResponse> {
|
|
31
|
+
// Also covers the test's own seed write (no processedAt yet) and a Remove
|
|
32
|
+
// (no new image at all), both of which arrive here and are not ours to act on.
|
|
33
|
+
if (!record.newImage?.processedAt || record.newImage.acknowledgedAt) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
|
|
38
|
+
...record.newImage,
|
|
39
|
+
acknowledgedAt: yield* askDateNow(),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './onTick';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
askKeyValueStoreGet,
|
|
3
|
+
askKeyValueStoreUpsert,
|
|
4
|
+
askLogCreate,
|
|
5
|
+
AskResponse,
|
|
6
|
+
LogLevelEnum,
|
|
7
|
+
ScheduledEventParams,
|
|
8
|
+
} from 'quidproquo';
|
|
9
|
+
|
|
10
|
+
import { SCHEDULE_TICK_NAME } from '../../constants/scheduleTickName';
|
|
11
|
+
import { SCHEDULE_TICK_STORE } from '../../constants/scheduleTickStore';
|
|
12
|
+
import { ScheduleTickRecord } from '../../models/ScheduleTickRecord';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The heartbeat. Deployed this is an EventBridge rule; locally the dev
|
|
16
|
+
* server's ticker fires it on the same minute.
|
|
17
|
+
*
|
|
18
|
+
* Marks a pending request as processed, and does nothing when there is no
|
|
19
|
+
* request outstanding - so between smoke runs this is a log line and a read,
|
|
20
|
+
* not a write every minute forever.
|
|
21
|
+
*
|
|
22
|
+
* Records the event's own `time` rather than reading a clock, because that is
|
|
23
|
+
* what the scheduler believes the firing minute to be, and agreement on that
|
|
24
|
+
* between the two runtimes is the thing worth proving.
|
|
25
|
+
*/
|
|
26
|
+
export function* onTick(event: ScheduledEventParams): AskResponse<void> {
|
|
27
|
+
yield* askLogCreate(
|
|
28
|
+
LogLevelEnum.Info,
|
|
29
|
+
`tick ${event.time} (correlation ${event.correlation})`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const pending = yield* askKeyValueStoreGet<ScheduleTickRecord>(
|
|
33
|
+
SCHEDULE_TICK_STORE,
|
|
34
|
+
SCHEDULE_TICK_NAME
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (!pending || pending.processedAt) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
yield* askKeyValueStoreUpsert<ScheduleTickRecord>(SCHEDULE_TICK_STORE, {
|
|
42
|
+
...pending,
|
|
43
|
+
processedAt: event.time,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './config';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The single row the whole schedule chain writes to, one field per actor:
|
|
2
|
+
//
|
|
3
|
+
// requestedAt the smoke test, seeding the run
|
|
4
|
+
// processedAt the schedule, when its minute comes round
|
|
5
|
+
// acknowledgedAt the store's own change stream, having seen processedAt
|
|
6
|
+
//
|
|
7
|
+
// `runId` is the smoke run that seeded it, so what the test reads back is
|
|
8
|
+
// provably its own rather than the previous run's. Nothing is ever deleted: a
|
|
9
|
+
// stale row is one whose runId does not match, so there is no window where the
|
|
10
|
+
// record is missing.
|
|
11
|
+
export type ScheduleTickRecord = {
|
|
12
|
+
scheduleName: string;
|
|
13
|
+
runId: string;
|
|
14
|
+
requestedAt: string;
|
|
15
|
+
processedAt?: string;
|
|
16
|
+
acknowledgedAt?: string;
|
|
17
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ScheduleTickRecord';
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: defineRecurringSchedule
|
|
3
|
-
description: Define a recurring schedule
|
|
3
|
+
description: Define a recurring schedule, a trigger that runs a story on a timetable.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# defineRecurringSchedule
|
|
7
7
|
|
|
8
|
-
Defines a **recurring schedule**: a time-based trigger that runs a story on a
|
|
8
|
+
Defines a **recurring schedule**: a time-based trigger that runs a story on a timetable, with no incoming request. Use it for periodic work — nightly cleanups, polling, report generation, cache warming. Like a [queue](./queue.md) or [event bus](./event-bus.md), a schedule is an **event source**: each fire delivers a `ScheduledEvent` to the target story through the same [askProcessEvent](../../actions/core/event/ask-process-event.md) pipeline.
|
|
9
9
|
|
|
10
|
-
- **
|
|
10
|
+
- **Locally:** the dev server ticks once a minute and runs any schedule whose recurrence matches that UTC minute. See [Locally](#locally) below.
|
|
11
|
+
- **On AWS:** deploys an **EventBridge rule** whose cron expression is rendered from the recurrence and a **consumer Lambda** as its target (`QpqCoreRecurringScheduleConstruct` in `quidproquo-deploy-awscdk`). The rule fires on the cron cadence and invokes the Lambda, passing the schedule's `metadata` as the event `detail`. The Lambda has a 15-minute timeout, and `maxConcurrentExecutions` (when set) becomes the Lambda's reserved concurrent executions.
|
|
11
12
|
|
|
12
13
|
```typescript
|
|
13
14
|
import { defineRecurringSchedule } from 'quidproquo-core';
|
|
14
15
|
|
|
15
16
|
export default [
|
|
16
|
-
// Every day at
|
|
17
|
-
defineRecurringSchedule(
|
|
17
|
+
// Every day at 3am UTC
|
|
18
|
+
defineRecurringSchedule({ dailyAtUtc: { hour: 3, minute: 0 } }, '/entry/schedule/onNightlyCleanup::onNightlyCleanup'),
|
|
18
19
|
];
|
|
19
20
|
```
|
|
20
21
|
|
|
@@ -22,7 +23,7 @@ export default [
|
|
|
22
23
|
|
|
23
24
|
```typescript
|
|
24
25
|
function defineRecurringSchedule(
|
|
25
|
-
|
|
26
|
+
recurrence: ScheduleRecurrence,
|
|
26
27
|
runtime: QpqFunctionRuntime,
|
|
27
28
|
options?: QPQConfigAdvancedScheduleSettings,
|
|
28
29
|
): ScheduleQPQConfigSetting;
|
|
@@ -30,29 +31,21 @@ function defineRecurringSchedule(
|
|
|
30
31
|
|
|
31
32
|
## Parameters
|
|
32
33
|
|
|
33
|
-
### `
|
|
34
|
+
### `recurrence` — `ScheduleRecurrence` (required)
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
When the schedule fires, declared as intent rather than as a cron string. It is deliberately platform-neutral: an AWS EventBridge cron expression is one *rendering* of it, produced at deploy time, and the dev server matches the same declaration against the clock.
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
| Recurrence | Fires |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `{ everyMinutes: n }` | every `n` minutes, on the hour. `n` must divide 60 |
|
|
41
|
+
| `{ everyHours: n, atMinute?: m }` | every `n` hours at minute `m` (default 0). `n` must divide 24 |
|
|
42
|
+
| `{ dailyAtUtc: { hour, minute } }` | once a day |
|
|
43
|
+
| `{ weeklyAtUtc: { day, hour, minute } }` | once a week, `day` being a `DayOfWeek` |
|
|
44
|
+
| `{ monthlyAtUtc: { dayOfMonth, hour, minute } }` | once a month. A date past the end of a short month simply does not occur that month |
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
| --- | --- | --- |
|
|
43
|
-
| Minutes | `0-59` | `,` `-` `*` `/` |
|
|
44
|
-
| Hours | `0-23` | `,` `-` `*` `/` |
|
|
45
|
-
| Day-of-month | `1-31` | `,` `-` `*` `?` `/` `L` `W` |
|
|
46
|
-
| Month | `1-12` or `JAN-DEC` | `,` `-` `*` `/` |
|
|
47
|
-
| Day-of-week | `1-7` or `SUN-SAT` | `,` `-` `*` `?` `L` `#` |
|
|
48
|
-
| Year | `1970-2199` | `,` `-` `*` `/` |
|
|
46
|
+
**Every time is UTC.** The deployed scheduler evaluates in UTC, so there is no local-timezone option: 3am in Brisbane is `{ dailyAtUtc: { hour: 17, minute: 0 } }`, and saying so in the config beats a comment that goes stale twice a year.
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `'* * * * ? *'` — every minute
|
|
53
|
-
- `'0/10 * * * ? *'` — every 10 minutes
|
|
54
|
-
- `'0 0 3 * * ? *'` — every day at 3 AM
|
|
55
|
-
- `'0 0 3 ? * 1 *'` — every Monday at 3 AM
|
|
48
|
+
An interval that cannot be scheduled evenly (`{ everyMinutes: 7 }`) throws an `InvalidScheduleRecurrenceError` when the config is evaluated, so it fails at synth and at dev-server boot rather than at some unlucky hour in production. The reason it is refused rather than approximated: AWS renders an interval as `0/n`, which restarts at the top of every hour, so seven-minute steps would fire at :00 :07 ... :56 and then leave a four-minute gap.
|
|
56
49
|
|
|
57
50
|
### `runtime` — `QpqFunctionRuntime` (required)
|
|
58
51
|
|
|
@@ -99,19 +92,29 @@ import { defineRecurringSchedule } from 'quidproquo-core';
|
|
|
99
92
|
|
|
100
93
|
export default [
|
|
101
94
|
// Poll an upstream every 10 minutes
|
|
102
|
-
defineRecurringSchedule(
|
|
95
|
+
defineRecurringSchedule({ everyMinutes: 10 }, '/entry/schedule/onPoll::onPoll'),
|
|
103
96
|
|
|
104
97
|
// Nightly report, capped to a single concurrent run, with metadata
|
|
105
|
-
defineRecurringSchedule(
|
|
98
|
+
defineRecurringSchedule({ dailyAtUtc: { hour: 2, minute: 0 } }, '/entry/schedule/onNightlyReport::onNightlyReport', {
|
|
106
99
|
maxConcurrentExecutions: 1,
|
|
107
100
|
metadata: { report: 'daily-summary' },
|
|
108
101
|
}),
|
|
109
102
|
];
|
|
110
103
|
```
|
|
111
104
|
|
|
105
|
+
## Locally
|
|
106
|
+
|
|
107
|
+
The dev server arms every schedule its services own and ticks once a minute, running any whose recurrence matches that UTC minute. It lists what it armed at boot:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
[schedule] 2 schedule(s) armed (utc):
|
|
111
|
+
[schedule] flow/onPoll {"everyMinutes":10}
|
|
112
|
+
[schedule] flow/onNightlyReport {"dailyAtUtc":{"hour":2,"minute":0}}
|
|
113
|
+
```
|
|
114
|
+
|
|
112
115
|
## Related
|
|
113
116
|
|
|
114
117
|
- [askProcessEvent](../../actions/core/event/ask-process-event.md) — the pipeline that runs the target story for each schedule fire.
|
|
115
118
|
- [defineQueue](./queue.md) and [defineEventBus](./event-bus.md) — the other core event sources.
|
|
116
119
|
- [defineDeployEvent](./deploy-event.md) — a related time/lifecycle-based trigger that runs at deploy time rather than on a timetable.
|
|
117
|
-
- **AWS implementation:** `QpqCoreRecurringScheduleConstruct` (EventBridge rule + target Lambda) in `quidproquo-deploy-awscdk`.
|
|
120
|
+
- **AWS implementation:** `QpqCoreRecurringScheduleConstruct` (EventBridge rule + target Lambda) in `quidproquo-deploy-awscdk`. `renderAwsCronExpression`, alongside it, is the only place in the codebase that knows the EventBridge cron dialect.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// it does not pass
|
|
4
|
-
//
|
|
2
|
+
// Runs the smoke suite against a just-deployed test service and exits non-zero
|
|
3
|
+
// if it does not pass. The run itself lives in scripts/smoke/runSmokeRun.mjs,
|
|
4
|
+
// shared with localSmoke.mjs; this file only resolves which api to point it at.
|
|
5
5
|
//
|
|
6
|
-
// Runs inside the
|
|
6
|
+
// Runs inside the deploy workflow's deploy job, which supplies:
|
|
7
7
|
// SMOKE_ENVIRONMENT the environment that was just deployed (development, staging)
|
|
8
8
|
// ACTIONS_ID_TOKEN_REQUEST_URL provided by Actions when the job has id-token: write
|
|
9
9
|
// ACTIONS_ID_TOKEN_REQUEST_TOKEN provided by Actions when the job has id-token: write
|
|
@@ -13,16 +13,13 @@
|
|
|
13
13
|
// the api gateway maps each service under its own base path (see the
|
|
14
14
|
// CfnBasePathMapping in quidproquo-deploy-awscdk's api construct):
|
|
15
15
|
// https://api.<environment>.<domain>/<service>
|
|
16
|
-
//
|
|
17
|
-
// No dependencies: Node 24's global fetch is all it needs.
|
|
18
16
|
|
|
19
17
|
import { readFileSync } from 'node:fs';
|
|
20
18
|
import { dirname, join } from 'node:path';
|
|
21
19
|
import { fileURLToPath } from 'node:url';
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const POLL_MS = 5 * 1000;
|
|
21
|
+
import { runSmokeRun } from './smoke/runSmokeRun.mjs';
|
|
22
|
+
|
|
26
23
|
const SERVICE_NAME = 'test';
|
|
27
24
|
const DEPLOY_CONFIG_PATH = join(
|
|
28
25
|
dirname(fileURLToPath(import.meta.url)),
|
|
@@ -56,79 +53,9 @@ const deriveApiUrl = (environment) => {
|
|
|
56
53
|
|
|
57
54
|
const environment = requireEnv('SMOKE_ENVIRONMENT');
|
|
58
55
|
const apiUrl = deriveApiUrl(environment);
|
|
59
|
-
const tokenRequestUrl = requireEnv('ACTIONS_ID_TOKEN_REQUEST_URL');
|
|
60
|
-
const tokenRequestToken = requireEnv('ACTIONS_ID_TOKEN_REQUEST_TOKEN');
|
|
61
56
|
|
|
62
57
|
log(`environment=${environment} api=${apiUrl}`);
|
|
63
58
|
|
|
64
|
-
|
|
65
|
-
// so every request mints a fresh one rather than racing the expiry.
|
|
66
|
-
const mintToken = async () => {
|
|
67
|
-
const response = await fetch(`${tokenRequestUrl}&audience=${AUDIENCE}`, {
|
|
68
|
-
headers: { Authorization: `bearer ${tokenRequestToken}` },
|
|
69
|
-
});
|
|
70
|
-
if (!response.ok) {
|
|
71
|
-
throw new Error(`token request failed with ${response.status}`);
|
|
72
|
-
}
|
|
73
|
-
const body = await response.json();
|
|
74
|
-
if (!body.value) {
|
|
75
|
-
throw new Error('token request returned no value');
|
|
76
|
-
}
|
|
77
|
-
return body.value;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const callApi = async (method, path) => {
|
|
81
|
-
const response = await fetch(`${apiUrl}${path}`, {
|
|
82
|
-
method,
|
|
83
|
-
headers: { Authorization: `Bearer ${await mintToken()}` },
|
|
84
|
-
});
|
|
85
|
-
if (!response.ok) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`${method} ${path} failed with ${response.status}: ${await response.text()}`
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
return response.json();
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
-
|
|
95
|
-
const printTests = (tests) => {
|
|
96
|
-
for (const test of tests) {
|
|
97
|
-
log(
|
|
98
|
-
` #${test.id} ${test.name.padEnd(24)} ${test.status.padEnd(8)} ${test.message}`
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const main = async () => {
|
|
104
|
-
const { runId } = await callApi('POST', '/smoke/run');
|
|
105
|
-
if (!runId) {
|
|
106
|
-
fail('POST /smoke/run returned no runId');
|
|
107
|
-
}
|
|
108
|
-
log(`started run ${runId}`);
|
|
109
|
-
|
|
110
|
-
const deadline = Date.now() + DEADLINE_MS;
|
|
111
|
-
while (Date.now() < deadline) {
|
|
112
|
-
const run = await callApi('GET', `/smoke/run/${runId}`);
|
|
113
|
-
const { summary } = run;
|
|
114
|
-
log(
|
|
115
|
-
`status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed}`
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
if (run.status === 'passed' || run.status === 'failed') {
|
|
119
|
-
printTests(run.tests);
|
|
120
|
-
if (run.status === 'failed') {
|
|
121
|
-
fail(`run ${runId} failed`);
|
|
122
|
-
}
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
await sleep(POLL_MS);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
fail(`timed out waiting for run ${runId}`);
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
main().catch((error) =>
|
|
59
|
+
runSmokeRun(apiUrl, log).catch((error) =>
|
|
133
60
|
fail(error instanceof Error ? error.message : String(error))
|
|
134
61
|
);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs the smoke suite against a dev server this script starts, and exits
|
|
3
|
+
// non-zero if it does not pass. The run itself lives in
|
|
4
|
+
// scripts/smoke/runSmokeRun.mjs, shared with deployedSmoke.mjs.
|
|
5
|
+
//
|
|
6
|
+
// This is a pre-flight gate, not a replacement for the deployed smoke: the
|
|
7
|
+
// same stories run against the dev server's implementations instead of AWS, so
|
|
8
|
+
// it catches a broken probe, or the dev server drifting from deployed
|
|
9
|
+
// behaviour, on a pull request rather than after a deploy. It cannot catch
|
|
10
|
+
// anything the deployed run exists for - iam grants, resource naming, cdk
|
|
11
|
+
// wiring, api gateway base path mapping.
|
|
12
|
+
//
|
|
13
|
+
// Needs a GitHub Actions job with `permissions: id-token: write` and
|
|
14
|
+
// `environment: development`, because the smoke routes validate the token's
|
|
15
|
+
// environment claim against the service's own module environment - and a dev
|
|
16
|
+
// server is always development (primeDeployEnvFromConfig defaults it), whatever
|
|
17
|
+
// environment the deploy that follows is targeting.
|
|
18
|
+
//
|
|
19
|
+
// The api url is derived, not configured, same as the deployed script - but to
|
|
20
|
+
// a different SHAPE, which is why the two derive separately rather than
|
|
21
|
+
// sharing a template. Deployed, api gateway maps each service under its own
|
|
22
|
+
// base path on the api domain. Locally the dev server routes on
|
|
23
|
+
// /{apiSubdomain}/{serviceName} (see apiImplementation's devPath), and the app
|
|
24
|
+
// declares defineApi('api', ...):
|
|
25
|
+
// http://localhost:8080/api/test
|
|
26
|
+
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
import { runSmokeRun } from './smoke/runSmokeRun.mjs';
|
|
32
|
+
|
|
33
|
+
// The dev server defaults to this anyway; set explicitly so a CI runner that
|
|
34
|
+
// already has ENVIRONMENT set (the deploy workflow does) cannot change what
|
|
35
|
+
// the smoke routes expect from the token. Must match the job's `environment:`.
|
|
36
|
+
const ENVIRONMENT = 'development';
|
|
37
|
+
|
|
38
|
+
// There is more than one app under apps/, so the app has to be named: without
|
|
39
|
+
// it the cli exits with the app list rather than picking one.
|
|
40
|
+
const APP_NAME = 'qpqjs';
|
|
41
|
+
|
|
42
|
+
// 8080 is the dev server's api port, set in the generated entry.
|
|
43
|
+
const BASE_URL = 'http://localhost:8080';
|
|
44
|
+
const API_URL = `${BASE_URL}/api/test`;
|
|
45
|
+
const READY_URL = `${BASE_URL}/admin/service/ready`;
|
|
46
|
+
|
|
47
|
+
// Generous: a cold start bundles every service with rspack before the server
|
|
48
|
+
// process even launches.
|
|
49
|
+
const READY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
50
|
+
const READY_POLL_MS = 2 * 1000;
|
|
51
|
+
|
|
52
|
+
// Above the dev server's own 5s worst-case shutdown budget.
|
|
53
|
+
const STOP_TIMEOUT_MS = 15 * 1000;
|
|
54
|
+
|
|
55
|
+
const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
56
|
+
|
|
57
|
+
// The .bin shim npm links on install, which points at the cli's built entry.
|
|
58
|
+
const QPQ_BIN = join(APP_ROOT, 'node_modules', '.bin', 'qpq');
|
|
59
|
+
|
|
60
|
+
const log = (message) => console.log(`local-smoke: ${message}`);
|
|
61
|
+
|
|
62
|
+
const fail = (message) => {
|
|
63
|
+
console.error(`local-smoke: ${message}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wait until the dev server reports every plugin started.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately not a TCP connect: the api plugin binds its port partway
|
|
73
|
+
* through the start sequence, so a socket opens before the queue and the
|
|
74
|
+
* stores are up, and the first POST would race them.
|
|
75
|
+
*/
|
|
76
|
+
const waitForReady = async (server) => {
|
|
77
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
78
|
+
|
|
79
|
+
while (Date.now() < deadline) {
|
|
80
|
+
if (server.exitCode !== null) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`dev server exited with ${server.exitCode} before it was ready`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(READY_URL);
|
|
88
|
+
if (response.ok) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Not listening yet. Nothing to report until the deadline passes.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await sleep(READY_POLL_MS);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new Error(`dev server was not ready within ${READY_TIMEOUT_MS}ms`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stop the dev server and resolve with how it went.
|
|
103
|
+
*
|
|
104
|
+
* SIGTERM rather than SIGKILL because the graceful path is part of what this
|
|
105
|
+
* script checks: the server should stop accepting, drain the smoke run's
|
|
106
|
+
* in-flight work and checkpoint its stores, then exit 0.
|
|
107
|
+
*
|
|
108
|
+
* Code and signal are both reported because they mean different things and
|
|
109
|
+
* only one of them is ever a number. A code of 0 is the pass; a non-zero code
|
|
110
|
+
* means a teardown broke or a phase ran out of budget; a null code with a
|
|
111
|
+
* signal means nothing handled the signal and the process was killed where it
|
|
112
|
+
* stood, which is a shutdown that never ran rather than one that failed.
|
|
113
|
+
*/
|
|
114
|
+
const stopServer = (server) =>
|
|
115
|
+
new Promise((resolve) => {
|
|
116
|
+
if (server.exitCode !== null) {
|
|
117
|
+
resolve({ code: server.exitCode, signal: null });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
log(
|
|
123
|
+
`dev server did not stop within ${STOP_TIMEOUT_MS}ms, sending SIGKILL`
|
|
124
|
+
);
|
|
125
|
+
server.kill('SIGKILL');
|
|
126
|
+
}, STOP_TIMEOUT_MS);
|
|
127
|
+
|
|
128
|
+
server.once('exit', (code, signal) => {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
resolve({ code, signal });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
server.kill('SIGTERM');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const main = async () => {
|
|
137
|
+
log(`starting dev server (app=${APP_NAME} env=${ENVIRONMENT})`);
|
|
138
|
+
|
|
139
|
+
// node on the cli directly, NOT `npx qpq`: npx is another process in front
|
|
140
|
+
// of the one we need to signal, and SIGTERM stops at it. The cli would never
|
|
141
|
+
// hear the stop, its dev server child would never drain, and the exit would
|
|
142
|
+
// come back as a signal kill. One less layer means the signal lands where
|
|
143
|
+
// the handler is.
|
|
144
|
+
const server = spawn(
|
|
145
|
+
process.execPath,
|
|
146
|
+
[QPQ_BIN, 'go:dev:api', '--app', APP_NAME],
|
|
147
|
+
{
|
|
148
|
+
cwd: APP_ROOT,
|
|
149
|
+
stdio: 'inherit',
|
|
150
|
+
env: { ...process.env, ENVIRONMENT },
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// The server is stopped either way, but a smoke failure is reported ahead of
|
|
155
|
+
// a dirty shutdown: it is the more useful of the two, and letting the
|
|
156
|
+
// shutdown check throw first would bury it.
|
|
157
|
+
let smokeError = null;
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await waitForReady(server);
|
|
161
|
+
log(`dev server ready, api=${API_URL}`);
|
|
162
|
+
|
|
163
|
+
await runSmokeRun(API_URL, log);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
smokeError = error;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { code, signal } = await stopServer(server);
|
|
169
|
+
log(`dev server exited with code=${code} signal=${signal}`);
|
|
170
|
+
|
|
171
|
+
if (smokeError) {
|
|
172
|
+
throw smokeError;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (signal) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`dev server was killed by ${signal} instead of shutting down, so its teardown never ran`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (code !== 0) {
|
|
182
|
+
throw new Error(`dev server did not shut down cleanly (exit ${code})`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
main().catch((error) =>
|
|
187
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
188
|
+
);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Drives one smoke run against a test service and reports whether it passed:
|
|
2
|
+
// mint a GitHub Actions OIDC token, POST /smoke/run, poll GET
|
|
3
|
+
// /smoke/run/{runId} until the run finishes or the deadline passes.
|
|
4
|
+
//
|
|
5
|
+
// Target-agnostic on purpose. The two callers differ only in the api url they
|
|
6
|
+
// resolve, which each derives for itself:
|
|
7
|
+
// deployedSmoke.mjs the just-deployed environment, from deploy.config.json
|
|
8
|
+
// localSmoke.mjs a dev server it started, on localhost
|
|
9
|
+
//
|
|
10
|
+
// Both talk to the same routes over the same protocol with the same token, so
|
|
11
|
+
// a local run rehearses the deployed one rather than approximating it.
|
|
12
|
+
//
|
|
13
|
+
// No dependencies: Node's global fetch is all it needs.
|
|
14
|
+
|
|
15
|
+
const AUDIENCE = 'qpq-smoke';
|
|
16
|
+
const DEADLINE_MS = 5 * 60 * 1000;
|
|
17
|
+
const POLL_MS = 5 * 1000;
|
|
18
|
+
|
|
19
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
|
|
21
|
+
// OIDC tokens are short-lived (minutes), about as long as the poll deadline,
|
|
22
|
+
// so every request mints a fresh one rather than racing the expiry.
|
|
23
|
+
const mintToken = async () => {
|
|
24
|
+
const tokenRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
25
|
+
const tokenRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
26
|
+
|
|
27
|
+
if (!tokenRequestUrl || !tokenRequestToken) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN are not set. This needs a GitHub Actions job with `permissions: id-token: write`'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const response = await fetch(`${tokenRequestUrl}&audience=${AUDIENCE}`, {
|
|
34
|
+
headers: { Authorization: `bearer ${tokenRequestToken}` },
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`token request failed with ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
const body = await response.json();
|
|
40
|
+
if (!body.value) {
|
|
41
|
+
throw new Error('token request returned no value');
|
|
42
|
+
}
|
|
43
|
+
return body.value;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const printTests = (log, tests) => {
|
|
47
|
+
for (const test of tests) {
|
|
48
|
+
log(
|
|
49
|
+
` #${test.id} ${test.name.padEnd(24)} ${test.status.padEnd(8)} ${test.message}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run the smoke suite against `apiUrl` and resolve if it passed.
|
|
56
|
+
*
|
|
57
|
+
* Throws on anything else (a failed run, an unreachable api, a run that never
|
|
58
|
+
* finishes) so a caller can let it reach its top-level catch and exit 1.
|
|
59
|
+
*/
|
|
60
|
+
export const runSmokeRun = async (apiUrl, log) => {
|
|
61
|
+
const callApi = async (method, path) => {
|
|
62
|
+
const response = await fetch(`${apiUrl}${path}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers: { Authorization: `Bearer ${await mintToken()}` },
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${method} ${path} failed with ${response.status}: ${await response.text()}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return response.json();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const { runId } = await callApi('POST', '/smoke/run');
|
|
75
|
+
if (!runId) {
|
|
76
|
+
throw new Error('POST /smoke/run returned no runId');
|
|
77
|
+
}
|
|
78
|
+
log(`started run ${runId}`);
|
|
79
|
+
|
|
80
|
+
const deadline = Date.now() + DEADLINE_MS;
|
|
81
|
+
while (Date.now() < deadline) {
|
|
82
|
+
const run = await callApi('GET', `/smoke/run/${runId}`);
|
|
83
|
+
const { summary } = run;
|
|
84
|
+
log(
|
|
85
|
+
`status=${run.status} completed ${summary.completed}/${summary.total} passed ${summary.passed} failed ${summary.failed}`
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (run.status === 'passed' || run.status === 'failed') {
|
|
89
|
+
printTests(log, run.tests);
|
|
90
|
+
if (run.status === 'failed') {
|
|
91
|
+
throw new Error(`run ${runId} failed`);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await sleep(POLL_MS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`timed out waiting for run ${runId}`);
|
|
100
|
+
};
|