@wtfalch/foundry 0.65.0 → 0.66.0
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/apps/cloudflare.d.ts +5 -0
- package/dist/apps/deploy.js +33 -1
- package/dist/apps/migrations.d.ts +3 -0
- package/dist/apps/migrations.js +32 -7
- package/dist/entry-rules.d.ts +11 -0
- package/dist/entry-rules.js +70 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0069_release_events.sql +16 -0
- package/dist/modules/database.js +22 -3
- package/dist/native-build.d.ts +16 -12
- package/dist/native-build.js +16 -12
- package/dist/plan/boule.js +2 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +17 -3
- package/dist/plan/executor.d.ts +43 -6
- package/dist/plan/executor.js +89 -18
- package/dist/plan/login-host.d.ts +8 -0
- package/dist/plan/login-host.js +8 -0
- package/dist/plan/plan-apply.d.ts +9 -0
- package/dist/plan/plan-apply.js +31 -1
- package/dist/plan/platform-diff.js +5 -0
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-providers.d.ts +8 -6
- package/dist/plan/platform-providers.js +21 -8
- package/dist/plan/release-host.d.ts +30 -1
- package/dist/plan/release-host.js +84 -41
- package/dist/plan/release-series-reader.d.ts +26 -0
- package/dist/plan/release-series-reader.js +40 -0
- package/dist/plan/sign-in-archon.d.ts +1 -1
- package/dist/plan/sign-in-archon.js +2 -2
- package/dist/plan/worker-app-database.d.ts +12 -1
- package/dist/plan/worker-app-database.js +13 -0
- package/dist/plan/worker-app.d.ts +27 -2
- package/dist/plan/worker-app.js +52 -7
- package/dist/plan/workspace.d.ts +2 -2
- package/dist/plan/workspace.js +3 -3
- package/dist/release/decide.d.ts +2 -0
- package/dist/release/decide.js +67 -10
- package/dist/release/events.d.ts +13 -0
- package/dist/release/events.js +67 -0
- package/dist/release/health.d.ts +5 -0
- package/dist/release/health.js +3 -3
- package/dist/release/run.d.ts +7 -2
- package/dist/release/run.js +8 -0
- package/dist/release/series.d.ts +58 -0
- package/dist/release/series.js +65 -0
- package/dist/release/state.d.ts +58 -1
- package/dist/release/state.js +45 -0
- package/dist/release/types.d.ts +44 -0
- package/dist/store/postgres.js +29 -3
- package/dist/store/schema.d.ts +112 -0
- package/dist/store/schema.js +9 -0
- package/dist/worker-app-config.d.ts +22 -1
- package/dist/worker-app-config.js +71 -13
- package/dist/worker-app-migrations.d.ts +6 -2
- package/dist/worker-app-migrations.js +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type ReleaseDeps, type StageCopyJob, type StageCopyOutcome } from '../release/run.js';
|
|
2
|
+
import { type ReleaseSeriesQuery } from '../release/series.js';
|
|
2
3
|
import { type ReleaseStore } from '../release/state.js';
|
|
4
|
+
import type { ReleaseSeries } from '../release/types.js';
|
|
3
5
|
import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
|
|
4
6
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
7
|
import type { CoolifyPort } from './executor.js';
|
|
@@ -18,6 +20,11 @@ export interface ReleaseHost {
|
|
|
18
20
|
refreshStage(): Promise<void>;
|
|
19
21
|
/** Writes the recorded weights to the balancer on every server that has one. */
|
|
20
22
|
writeWeights(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Reporting's release series for `entry` (C2), read through the balancer like the health read. Null when
|
|
25
|
+
* `entry` is not this host's, the entry has no reporting, or the read failed.
|
|
26
|
+
*/
|
|
27
|
+
readSeries?(entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
|
|
21
28
|
}
|
|
22
29
|
export interface ReleaseHostInput {
|
|
23
30
|
subject: BlueGreenSubject;
|
|
@@ -47,6 +54,27 @@ export interface ReleaseHostInput {
|
|
|
47
54
|
/** A stage deploy waits at most this many polls of this long (10 minutes) for Coolify's deployment to finish. */
|
|
48
55
|
export declare const STAGE_DEPLOY_ATTEMPTS = 60;
|
|
49
56
|
export declare const STAGE_DEPLOY_INTERVAL_MS = 10000;
|
|
57
|
+
/** What the balancer read of a subject needs: its address, its pin key and its health token. */
|
|
58
|
+
interface SubjectRead {
|
|
59
|
+
subject: BlueGreenSubject;
|
|
60
|
+
healthToken: ReleaseHostInput['healthToken'];
|
|
61
|
+
secret: ReleaseHostInput['secret'];
|
|
62
|
+
pinRef: string;
|
|
63
|
+
heldKeys?: PinHeldKeys;
|
|
64
|
+
fetch?: typeof fetch;
|
|
65
|
+
renew?: () => Promise<void>;
|
|
66
|
+
label: string;
|
|
67
|
+
}
|
|
68
|
+
type Recorded = () => Readonly<Record<string, string>>;
|
|
69
|
+
/**
|
|
70
|
+
* Reporting's release series of one subject (C2), read through the balancer like the health read. Null when the
|
|
71
|
+
* subject has no reporting, the token is not held, or the read failed. Used by a release run and, outside a run,
|
|
72
|
+
* by {@link readEntryReleaseSeries}.
|
|
73
|
+
*/
|
|
74
|
+
export declare function readSubjectSeries(read: SubjectRead & {
|
|
75
|
+
recorded: Recorded;
|
|
76
|
+
query: ReleaseSeriesQuery;
|
|
77
|
+
}): Promise<ReleaseSeries | null>;
|
|
50
78
|
export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
|
|
51
79
|
/**
|
|
52
80
|
* One release run for one subject, writing into `context` (the live recorded ids of the run that holds
|
|
@@ -56,7 +84,7 @@ export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
|
|
|
56
84
|
* (like the next apply, through its `bluegreen-lb` step) writes it again.
|
|
57
85
|
*/
|
|
58
86
|
export declare function runSubjectRelease(input: {
|
|
59
|
-
store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests'
|
|
87
|
+
store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests'> & Partial<Pick<ReleaseStore, 'appendEvents'>>;
|
|
60
88
|
scopeOwnerId: string;
|
|
61
89
|
subject: BlueGreenSubject;
|
|
62
90
|
host: ReleaseHost;
|
|
@@ -90,3 +118,4 @@ export declare function finishStageCopy(input: {
|
|
|
90
118
|
outcome: StageCopyOutcome;
|
|
91
119
|
refused: string[];
|
|
92
120
|
}>;
|
|
121
|
+
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { checkColorAddress, readReleaseHealth } from '../release/health.js';
|
|
3
3
|
import { runRelease, runStageCopy, settleStageCopy, } from '../release/run.js';
|
|
4
|
+
import { fetchReleaseSeries } from '../release/series.js';
|
|
4
5
|
import { releaseKeys } from '../release/state.js';
|
|
5
6
|
import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
|
|
6
7
|
import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
|
|
@@ -20,6 +21,68 @@ function statusOf(status) {
|
|
|
20
21
|
return 'other';
|
|
21
22
|
}
|
|
22
23
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
const pinInputOf = (read, recorded) => ({
|
|
25
|
+
id: read.subject.id,
|
|
26
|
+
ref: read.pinRef,
|
|
27
|
+
recorded,
|
|
28
|
+
secret: read.secret,
|
|
29
|
+
heldKeys: read.heldKeys ?? { resolve: async () => null },
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Where the runner reaches a color: the entry's first public hostname, pinned to the color by the balancer's
|
|
33
|
+
* header router. The balancer routes the pinned request to that color whatever its weight.
|
|
34
|
+
*/
|
|
35
|
+
async function colorAddress(read, recorded, color) {
|
|
36
|
+
const hostname = read.subject.hostnames.find((name) => !name.startsWith('*'));
|
|
37
|
+
if (!hostname)
|
|
38
|
+
throw new Error(`${read.label}: the app has no public hostname to check through`);
|
|
39
|
+
const pinKey = await readPinKey(pinInputOf(read, recorded));
|
|
40
|
+
if (!pinKey)
|
|
41
|
+
throw new Error(`${read.label}: the balancer's pin key is not held`);
|
|
42
|
+
return {
|
|
43
|
+
address: `https://${hostname}`,
|
|
44
|
+
headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The color a health read goes to: the one being released, else the live one. */
|
|
48
|
+
function healthColorOf(ids, id) {
|
|
49
|
+
try {
|
|
50
|
+
const window = JSON.parse(ids[bgKey.window(id)] ?? '{}');
|
|
51
|
+
if (window.release?.to === 'blue' || window.release?.to === 'green')
|
|
52
|
+
return window.release.to;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A damaged window reads as no release; the runner rewrites it.
|
|
56
|
+
}
|
|
57
|
+
return ids[bgKey.live(id)] === 'green' ? 'green' : 'blue';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reporting's release series of one subject (C2), read through the balancer like the health read. Null when the
|
|
61
|
+
* subject has no reporting, the token is not held, or the read failed. Used by a release run and, outside a run,
|
|
62
|
+
* by {@link readEntryReleaseSeries}.
|
|
63
|
+
*/
|
|
64
|
+
export async function readSubjectSeries(read) {
|
|
65
|
+
if (!read.subject.reporting)
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
await read.renew?.();
|
|
69
|
+
const token = await read.healthToken();
|
|
70
|
+
if (!token)
|
|
71
|
+
return null;
|
|
72
|
+
read.secret(token);
|
|
73
|
+
return await fetchReleaseSeries({
|
|
74
|
+
...(await colorAddress(read, read.recorded, healthColorOf(read.recorded(), read.subject.id))),
|
|
75
|
+
token,
|
|
76
|
+
query: read.query,
|
|
77
|
+
...(read.fetch ? { fetchImpl: read.fetch } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// No hostname or pin key yet: the page shows no chart. The cause can name a hostname, so it is not logged.
|
|
82
|
+
console.error(`${read.label}: reading the release series failed`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
23
86
|
export function createReleaseHost(input) {
|
|
24
87
|
const { subject, context, coolify } = input;
|
|
25
88
|
const id = subject.id;
|
|
@@ -43,41 +106,9 @@ export function createReleaseHost(input) {
|
|
|
43
106
|
};
|
|
44
107
|
const colorApps = (color) => colorAppsOnServers(color).map((a) => a.appId);
|
|
45
108
|
const primaryColorApp = (color) => color === 'blue' ? input.primaryBlueAppId : recorded()[bgKey.app(id, input.primaryUuid, color)];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*/
|
|
50
|
-
const pinInput = () => ({
|
|
51
|
-
id,
|
|
52
|
-
ref: input.pinRef,
|
|
53
|
-
recorded,
|
|
54
|
-
secret: input.secret,
|
|
55
|
-
heldKeys: input.heldKeys ?? { resolve: async () => null },
|
|
56
|
-
});
|
|
57
|
-
const addressOf = async (color) => {
|
|
58
|
-
const hostname = subject.hostnames.find((name) => !name.startsWith('*'));
|
|
59
|
-
if (!hostname)
|
|
60
|
-
throw new Error(`${input.label}: the app has no public hostname to check through`);
|
|
61
|
-
const pinKey = await readPinKey(pinInput());
|
|
62
|
-
if (!pinKey)
|
|
63
|
-
throw new Error(`${input.label}: the balancer's pin key is not held`);
|
|
64
|
-
return {
|
|
65
|
-
address: `https://${hostname}`,
|
|
66
|
-
headers: { [LB_COLOR_HEADER]: color, [LB_PIN_HEADER]: pinKey },
|
|
67
|
-
};
|
|
68
|
-
};
|
|
69
|
-
/** The color a health read goes to: the one being released, else the live one. */
|
|
70
|
-
const healthColor = () => {
|
|
71
|
-
try {
|
|
72
|
-
const window = JSON.parse(recorded()[bgKey.window(id)] ?? '{}');
|
|
73
|
-
if (window.release?.to === 'blue' || window.release?.to === 'green')
|
|
74
|
-
return window.release.to;
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
// A damaged window reads as no release; the runner rewrites it.
|
|
78
|
-
}
|
|
79
|
-
return recorded()[bgKey.live(id)] === 'green' ? 'green' : 'blue';
|
|
80
|
-
};
|
|
109
|
+
const pinInput = () => pinInputOf(input, recorded);
|
|
110
|
+
const addressOf = (color) => colorAddress(input, recorded, color);
|
|
111
|
+
const healthColor = () => healthColorOf(recorded(), id);
|
|
81
112
|
const deployTo = async (color, digest) => {
|
|
82
113
|
if (!COMMIT.test(digest))
|
|
83
114
|
throw new Error(`${input.label}: the release is not a built commit`);
|
|
@@ -250,6 +281,11 @@ export function createReleaseHost(input) {
|
|
|
250
281
|
};
|
|
251
282
|
return {
|
|
252
283
|
deps,
|
|
284
|
+
async readSeries(entry, query) {
|
|
285
|
+
if (entry !== id)
|
|
286
|
+
return null;
|
|
287
|
+
return readSubjectSeries({ ...input, recorded, query });
|
|
288
|
+
},
|
|
253
289
|
async refreshStage() {
|
|
254
290
|
const stageId = recorded()[bgKey.app(id, input.primaryUuid, 'stage')];
|
|
255
291
|
if (!stageId || !coolify.listApplicationDeployments)
|
|
@@ -324,13 +360,7 @@ export async function runSubjectRelease(input) {
|
|
|
324
360
|
const result = await runRelease({
|
|
325
361
|
store: {
|
|
326
362
|
getScopeIds: async () => ({ ...ids() }),
|
|
327
|
-
addRequest: async () => {
|
|
328
|
-
throw new Error('the release runner takes requests; it does not add them');
|
|
329
|
-
},
|
|
330
363
|
openRequests: (query) => input.store.openRequests(query),
|
|
331
|
-
consumeRequests: async () => {
|
|
332
|
-
throw new Error('the release runner consumes requests after it persists');
|
|
333
|
-
},
|
|
334
364
|
},
|
|
335
365
|
orgId: input.scopeOwnerId,
|
|
336
366
|
entryKey: id,
|
|
@@ -347,6 +377,19 @@ export async function runSubjectRelease(input) {
|
|
|
347
377
|
context.recordScopeId(key, value);
|
|
348
378
|
}
|
|
349
379
|
await context.persist();
|
|
380
|
+
if (input.store.appendEvents && result.events.length > 0) {
|
|
381
|
+
try {
|
|
382
|
+
await input.store.appendEvents({
|
|
383
|
+
orgId: input.scopeOwnerId,
|
|
384
|
+
entryKey: id,
|
|
385
|
+
events: result.events,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// The history is a record, not a gate: losing a line must not leave the run's requests open to replay.
|
|
390
|
+
console.error(`release ${id}: recording ${result.events.length} history lines failed`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
350
393
|
await input.store.consumeRequests(result.requestIds);
|
|
351
394
|
const after = parseWeights(ids()[bgKey.weights(id)]);
|
|
352
395
|
// Also when a balancer lags the weights (an earlier write failed), so the next tick retries it.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReleaseSeriesQuery } from '../release/series.js';
|
|
2
|
+
import type { ReleaseStore } from '../release/state.js';
|
|
3
|
+
import type { ReleaseSeries } from '../release/types.js';
|
|
4
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
5
|
+
import { type PinHeldKeys } from './blue-green.js';
|
|
6
|
+
/** What a request needs to read a series outside a release run: what Archon already holds. */
|
|
7
|
+
export interface EntrySeriesDeps {
|
|
8
|
+
orgs: Pick<DeclaredOrgStore, 'get'>;
|
|
9
|
+
/** The recorded ids of the org, as the release tick persists them (`createPostgresReleaseStore`). */
|
|
10
|
+
store: Pick<ReleaseStore, 'getScopeIds'>;
|
|
11
|
+
/** The held store: reads the entry's health token and the balancer's pin key. */
|
|
12
|
+
heldKeys: Pick<PinHeldKeys, 'resolve'>;
|
|
13
|
+
/** Registers a value for redaction before it is used. */
|
|
14
|
+
secret?: (value: string) => void;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* C2 for a request: the series of one blue/green entry, read the way a release run reads it (the entry's
|
|
19
|
+
* first public hostname, the colour pin headers, the health token, a 15 s timeout, a 1 MiB cap). Null for an
|
|
20
|
+
* org or entry that is not declared blue/green, an app without reporting, a token or pin key not held, and any
|
|
21
|
+
* failed read; a failure logs fixed text only. Never mints and never writes.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readEntryReleaseSeries(deps: EntrySeriesDeps, input: {
|
|
24
|
+
orgId: string;
|
|
25
|
+
entryKey: string;
|
|
26
|
+
} & ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { sharedSecretRef } from '../org.js';
|
|
2
|
+
import { blueGreenSubjects, healthTokenName, pinKeyName } from './blue-green.js';
|
|
3
|
+
import { readSubjectSeries } from './release-host.js';
|
|
4
|
+
/**
|
|
5
|
+
* C2 for a request: the series of one blue/green entry, read the way a release run reads it (the entry's
|
|
6
|
+
* first public hostname, the colour pin headers, the health token, a 15 s timeout, a 1 MiB cap). Null for an
|
|
7
|
+
* org or entry that is not declared blue/green, an app without reporting, a token or pin key not held, and any
|
|
8
|
+
* failed read; a failure logs fixed text only. Never mints and never writes.
|
|
9
|
+
*/
|
|
10
|
+
export async function readEntryReleaseSeries(deps, input) {
|
|
11
|
+
const label = `coolify:release:${input.entryKey}`;
|
|
12
|
+
try {
|
|
13
|
+
const org = await deps.orgs.get(input.orgId);
|
|
14
|
+
if (!org)
|
|
15
|
+
return null;
|
|
16
|
+
const ids = await deps.store.getScopeIds(input.orgId);
|
|
17
|
+
const subject = blueGreenSubjects(org, ids).find((s) => s.id === input.entryKey);
|
|
18
|
+
if (!subject)
|
|
19
|
+
return null;
|
|
20
|
+
return await readSubjectSeries({
|
|
21
|
+
subject,
|
|
22
|
+
recorded: () => ids,
|
|
23
|
+
healthToken: async () => (await deps.heldKeys.resolve(sharedSecretRef(org.id, healthTokenName(subject.id)))) ?? null,
|
|
24
|
+
secret: deps.secret ?? (() => undefined),
|
|
25
|
+
pinRef: sharedSecretRef(org.id, pinKeyName(subject.id)),
|
|
26
|
+
heldKeys: deps.heldKeys,
|
|
27
|
+
...(deps.fetch ? { fetch: deps.fetch } : {}),
|
|
28
|
+
label,
|
|
29
|
+
query: {
|
|
30
|
+
from: input.from,
|
|
31
|
+
to: input.to,
|
|
32
|
+
...(input.bucket !== undefined ? { bucket: input.bucket } : {}),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
console.error(`${label}: reading the release series failed`);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -9,7 +9,7 @@ export declare const PLATFORM_ARCHON_IN_APP_SCOPE_KEY = "platform_archon_in_app"
|
|
|
9
9
|
/** The host a recorded {@link PLATFORM_ARCHON_IN_APP_SCOPE_KEY} names; `''` when none. */
|
|
10
10
|
export declare function archonInAppHost(recorded: Readonly<Record<string, string>>): string;
|
|
11
11
|
/** The ZITADEL calls Archon's in-app sign-in makes, on the owner company's own instance. */
|
|
12
|
-
export type ArchonInAppZitadel = Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri'> & Partial<Pick<ZitadelPort, 'allowPerAppLoginBaseUri'>>;
|
|
12
|
+
export type ArchonInAppZitadel = Pick<ZitadelPort, 'ensureTrustedDomain' | 'deleteTrustedDomain' | 'updateOidcAppLoginBaseUri' | 'listApps'> & Partial<Pick<ZitadelPort, 'allowPerAppLoginBaseUri' | 'listOrgs' | 'listProjects'>>;
|
|
13
13
|
interface ArchonInAppInput {
|
|
14
14
|
zitadel: ArchonInAppZitadel;
|
|
15
15
|
coolify: Pick<CoolifyPort, 'listEnvs' | 'bulkUpsertEnvs' | 'deploy'>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isNotFoundError, setOidcAppLoginBaseUri } from './executor.js';
|
|
1
|
+
import { allowPerAppLoginBaseUris, isNotFoundError, setOidcAppLoginBaseUri } from './executor.js';
|
|
2
2
|
import { editSignInBindings } from './sign-in-bindings.js';
|
|
3
3
|
import { SIGN_IN_BASE_PATH } from './sign-in.js';
|
|
4
4
|
/**
|
|
@@ -41,7 +41,7 @@ export async function archonInAppPoint(input) {
|
|
|
41
41
|
if (!zitadel.allowPerAppLoginBaseUri) {
|
|
42
42
|
throw new Error("platform:archon: the owner company's ZITADEL port cannot allow a per-app login base URI, so Archon cannot sign in on its own address");
|
|
43
43
|
}
|
|
44
|
-
await zitadel
|
|
44
|
+
await allowPerAppLoginBaseUris(zitadel, `https://${input.loginHost}/`);
|
|
45
45
|
await setOidcAppLoginBaseUri(zitadel, input.organisationId, input.projectId, input.appId, `https://${host}${SIGN_IN_BASE_PATH}/`);
|
|
46
46
|
}
|
|
47
47
|
/** First half of giving the address up: the client goes back to the company's login host. */
|
|
@@ -18,7 +18,7 @@ export { type DatabaseMigration, MIGRATIONS_ASSET_NAME, WORKER_APP_MIGRATION_TIM
|
|
|
18
18
|
* database it cannot use:
|
|
19
19
|
* Access app and service token -> tunnel rule and DNS -> `<db>_rt` password -> release
|
|
20
20
|
* `migrations.json` (its sha256 checked against `database.migrationsSha256`) applied with the owner credentials -> runtime role (UPDATE, DELETE and
|
|
21
|
-
* TRUNCATE revoked on `appendOnly`) -> Hyperdrive config with the runtime credentials.
|
|
21
|
+
* TRUNCATE revoked on `appendOnly`, DELETE and TRUNCATE on `noDelete`) -> Hyperdrive config with the runtime credentials.
|
|
22
22
|
* A failed migration throws, so the Worker is not uploaded.
|
|
23
23
|
*/
|
|
24
24
|
/** Everything needed to open a connection to the org's database over the tunnel. */
|
|
@@ -75,6 +75,16 @@ export interface WorkerAppDatabasePort extends WorkerAppMigrationRunner<Hyperdri
|
|
|
75
75
|
}): Promise<void>;
|
|
76
76
|
/** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
|
|
77
77
|
fetchReleaseAsset(url: string): Promise<string | null>;
|
|
78
|
+
/**
|
|
79
|
+
* After the runtime role work: the names among `tables` that `user` can still delete from or
|
|
80
|
+
* truncate (itself, through PUBLIC or through a role it belongs to), or that do not exist.
|
|
81
|
+
* Optional: without it a `noDelete` list is blocked at plan time.
|
|
82
|
+
*/
|
|
83
|
+
deletableTables?(input: {
|
|
84
|
+
owner: HyperdriveOrigin;
|
|
85
|
+
user: string;
|
|
86
|
+
tables: string[];
|
|
87
|
+
}): Promise<string[]>;
|
|
78
88
|
}
|
|
79
89
|
/** The Hyperdrive config, `{name, id}` as JSON. No secret. */
|
|
80
90
|
export declare const workerAppHyperdriveScopeKey: (entryKey: string) => string;
|
|
@@ -130,6 +140,7 @@ export declare function applyWorkerAppDatabase(deps: WorkerAppDatabaseDeps, org:
|
|
|
130
140
|
artifactUrl: string;
|
|
131
141
|
database: {
|
|
132
142
|
appendOnly: string[];
|
|
143
|
+
noDelete?: string[];
|
|
133
144
|
grants?: string[];
|
|
134
145
|
migrationsSha256?: string;
|
|
135
146
|
};
|
|
@@ -145,6 +145,19 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
145
145
|
await context.persistScopeIds();
|
|
146
146
|
},
|
|
147
147
|
});
|
|
148
|
+
// 5a. `noDelete`: read the role's rights back, so a port that dropped the list cannot pass.
|
|
149
|
+
const noDelete = want.database.noDelete ?? [];
|
|
150
|
+
if (noDelete.length > 0) {
|
|
151
|
+
const read = port.deletableTables;
|
|
152
|
+
if (!read) {
|
|
153
|
+
throw new Error(`${at}: the host's database port cannot read the role's rights back (no deletableTables)`);
|
|
154
|
+
}
|
|
155
|
+
const open = await call("check the runtime role's rights", () => read.call(port, { owner: ownerOrigin, user: runtimeUser, tables: [...noDelete] }));
|
|
156
|
+
const named = noDelete.filter((table) => open.includes(table));
|
|
157
|
+
if (named.length > 0) {
|
|
158
|
+
throw new Error(`${at}: the runtime role can still delete from ${named.join(', ')} (or the table is missing); the Worker was not deployed`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
148
161
|
// 5b. `uses.audit`: the sealing role, apart from the runtime role, after the audit tables exist.
|
|
149
162
|
if (entry.uses?.audit === true && port.ensureSealerRole) {
|
|
150
163
|
await ensureAuditSealer(deps, org, entry, owner.database, ownerOrigin, at, call, context, put);
|
|
@@ -67,6 +67,10 @@ export declare const WORKER_APP_AUTH_KEY_SECRET = "AUTH_APP_KEY";
|
|
|
67
67
|
* and name are hashed: a held ref is at most 128 characters.
|
|
68
68
|
*/
|
|
69
69
|
export declare const workerAppSecretRef: (orgId: string, entryKey: string, name: string) => string;
|
|
70
|
+
/** The version recorded for a `heldSecret` value: a short hash, so a changed value is delivered again. Never the value. */
|
|
71
|
+
export declare const heldSecretVersion: (value: string) => string;
|
|
72
|
+
/** `<entry key>/<NAME>` of a `heldSecret`: how the plan's readiness names it. */
|
|
73
|
+
export declare const heldSecretId: (entryKey: string, name: string) => string;
|
|
70
74
|
/**
|
|
71
75
|
* The Cloudflare calls a Worker app needs. Edge 0.13.0 has none of the Durable
|
|
72
76
|
* Object, migration or container calls, so no adapter ships with this port yet
|
|
@@ -97,6 +101,11 @@ export interface WorkerAppPort {
|
|
|
97
101
|
value: string;
|
|
98
102
|
version: string;
|
|
99
103
|
}[]): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Sets the script's cron triggers to exactly `crons`; an empty list clears them. Safe to repeat.
|
|
106
|
+
* Edge's `client.platform.workers.setSchedules(name, { crons })`.
|
|
107
|
+
*/
|
|
108
|
+
setSchedules(name: string, crons: readonly string[]): Promise<void>;
|
|
100
109
|
/** A no-op when the secret is gone. */
|
|
101
110
|
deleteSecret(name: string, secretName: string): Promise<void>;
|
|
102
111
|
/** Creates or updates the container application of `className` on the script; returns its id. */
|
|
@@ -155,14 +164,21 @@ export interface WorkerAppWant {
|
|
|
155
164
|
/** `workerApp.database`: the Hyperdrive binding and the tables the runtime role may only append to, sorted. */
|
|
156
165
|
database?: {
|
|
157
166
|
appendOnly: string[];
|
|
167
|
+
noDelete?: string[];
|
|
158
168
|
grants?: string[];
|
|
159
169
|
migrationsSha256?: string;
|
|
160
170
|
};
|
|
161
|
-
/**
|
|
171
|
+
/** `workerApp.triggers.crons`, sorted. Absent when none, so a Worker without cron keeps the marker it already has. */
|
|
172
|
+
crons?: string[];
|
|
173
|
+
/**
|
|
174
|
+
* Secret names and where each comes from, sorted. A `heldSecret` is listed only while a value is held,
|
|
175
|
+
* with the version of that value (a hash, never the value); one with no value is not here.
|
|
176
|
+
*/
|
|
162
177
|
secrets: {
|
|
163
178
|
name: string;
|
|
164
|
-
source: 'generated' | 'keysCustody';
|
|
179
|
+
source: 'generated' | 'keysCustody' | 'heldSecret';
|
|
165
180
|
hostId?: string;
|
|
181
|
+
version?: string;
|
|
166
182
|
}[];
|
|
167
183
|
}
|
|
168
184
|
/** The zone apex the entry's route lives in. */
|
|
@@ -174,8 +190,11 @@ export declare const workerAppApex: (org: DeclaredOrg, entry?: HostingEntry) =>
|
|
|
174
190
|
export declare function wantedWorkerApp(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, input: {
|
|
175
191
|
issuer: string | null;
|
|
176
192
|
keysUrl?: string;
|
|
193
|
+
/** `<entry key>/<NAME>` -> version of the value held now, for each `heldSecret` that has one. */
|
|
194
|
+
heldSecrets?: Readonly<Record<string, string>>;
|
|
177
195
|
}): {
|
|
178
196
|
want: WorkerAppWant;
|
|
197
|
+
unset: string[];
|
|
179
198
|
} | {
|
|
180
199
|
missing: string[];
|
|
181
200
|
};
|
|
@@ -201,6 +220,8 @@ export interface WorkerAppPlanInput {
|
|
|
201
220
|
canHoldSecrets: boolean;
|
|
202
221
|
/** The sign-in issuer for `authEnv`. */
|
|
203
222
|
issuer: string | null;
|
|
223
|
+
/** `<entry key>/<NAME>` -> version of the value held now, for each `heldSecret` that has one (names only, never a value). */
|
|
224
|
+
heldSecrets?: Readonly<Record<string, string>>;
|
|
204
225
|
/** The platform's Cloudflare account id: a container image must live in its registry. */
|
|
205
226
|
registryAccountId?: string;
|
|
206
227
|
/**
|
|
@@ -214,6 +235,8 @@ export interface WorkerAppPlanInput {
|
|
|
214
235
|
tunnelReady: boolean;
|
|
215
236
|
/** The executor has Access, tunnel, DNS and zone ports. */
|
|
216
237
|
accessReady: boolean;
|
|
238
|
+
/** The port has `deletableTables`, so a `noDelete` list can be proven after the role work. */
|
|
239
|
+
noDeleteCheck?: boolean;
|
|
217
240
|
};
|
|
218
241
|
}
|
|
219
242
|
/** Every entry key with a deployed or half-deployed Worker app recorded. */
|
|
@@ -245,6 +268,8 @@ export interface WorkerAppDeps {
|
|
|
245
268
|
};
|
|
246
269
|
/** `workerApp.database`: the Hyperdrive port and the route's Cloudflare ports. */
|
|
247
270
|
database?: WorkerAppDatabaseDeps;
|
|
271
|
+
/** Registers a value for redaction before anything else can see it. */
|
|
272
|
+
secret?: (value: string) => void;
|
|
248
273
|
/** Test hook: the timeout, in ms. */
|
|
249
274
|
timeoutMs?: number;
|
|
250
275
|
}
|
package/dist/plan/worker-app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { APPS_DOMAIN } from '../org.js';
|
|
2
|
+
import { APPS_DOMAIN, heldEnvRef } from '../org.js';
|
|
3
3
|
import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
|
|
4
4
|
import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
|
|
5
5
|
import { entryAuthEnv } from './broker-entries.js';
|
|
@@ -92,6 +92,10 @@ export const WORKER_APP_AUTH_KEY_SECRET = 'AUTH_APP_KEY';
|
|
|
92
92
|
*/
|
|
93
93
|
export const workerAppSecretRef = (orgId, entryKey, name) => `foundry:worker-app:${orgId}:${createHash('sha256').update(`${entryKey}:${name}`).digest('hex').slice(0, 32)}`;
|
|
94
94
|
const secretVersion = (value) => `g1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
95
|
+
/** The version recorded for a `heldSecret` value: a short hash, so a changed value is delivered again. Never the value. */
|
|
96
|
+
export const heldSecretVersion = (value) => `h1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
97
|
+
/** `<entry key>/<NAME>` of a `heldSecret`: how the plan's readiness names it. */
|
|
98
|
+
export const heldSecretId = (entryKey, name) => `${entryKey}/${name}`;
|
|
95
99
|
/** Foundry mints a new credential this long before the held one expires. */
|
|
96
100
|
export const WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
97
101
|
/**
|
|
@@ -112,8 +116,8 @@ export const workerAppApex = (org, entry) => entry && workspaceWorkerParent(entr
|
|
|
112
116
|
: org.hosting.usesDomain && org.domain
|
|
113
117
|
? org.domain
|
|
114
118
|
: WORKER_APP_OIKOS_ZONE;
|
|
115
|
-
/** True when the want has a secret
|
|
116
|
-
const holdsGenerated = (want) => want.secrets.
|
|
119
|
+
/** True when the want has a secret foundry must hold (a generated one, the broker key, a custody credential). A `heldSecret` is the company's: it is only read. */
|
|
120
|
+
const holdsGenerated = (want) => want.secrets.some((s) => s.source !== 'heldSecret');
|
|
117
121
|
/**
|
|
118
122
|
* What the Worker must look like, or the inputs still missing (names only).
|
|
119
123
|
* `issuer` is the sign-in issuer the plan read; `keysUrl` the custody Worker.
|
|
@@ -123,8 +127,16 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
123
127
|
const missing = [];
|
|
124
128
|
const vars = { ...app.vars };
|
|
125
129
|
const secrets = [];
|
|
130
|
+
const unset = [];
|
|
126
131
|
for (const [name, source] of Object.entries(app.secrets ?? {})) {
|
|
127
|
-
if (source.from === '
|
|
132
|
+
if (source.from === 'heldSecret') {
|
|
133
|
+
const version = input.heldSecrets?.[heldSecretId(entry.key, name)];
|
|
134
|
+
if (version)
|
|
135
|
+
secrets.push({ name, source: 'heldSecret', version });
|
|
136
|
+
else
|
|
137
|
+
unset.push(name);
|
|
138
|
+
}
|
|
139
|
+
else if (source.from === 'keysCustody') {
|
|
128
140
|
const hostId = workerAppHostId(org.id, entry.key);
|
|
129
141
|
if (input.keysUrl === undefined)
|
|
130
142
|
missing.push('the keys custody port');
|
|
@@ -165,7 +177,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
165
177
|
if (missing.length > 0)
|
|
166
178
|
return { missing };
|
|
167
179
|
const shape = workerAppShape(app);
|
|
180
|
+
const crons = [...(app.triggers?.crons ?? [])].sort();
|
|
168
181
|
return {
|
|
182
|
+
unset: unset.sort(),
|
|
169
183
|
want: {
|
|
170
184
|
name: workerAppName(org, entry),
|
|
171
185
|
artifactUrl: shape.artifactUrl,
|
|
@@ -182,6 +196,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
182
196
|
? {
|
|
183
197
|
database: {
|
|
184
198
|
appendOnly: [...(app.database.appendOnly ?? [])].sort(),
|
|
199
|
+
...(app.database.noDelete?.length
|
|
200
|
+
? { noDelete: [...app.database.noDelete].sort() }
|
|
201
|
+
: {}),
|
|
185
202
|
...(app.database.grants?.length ? { grants: [...app.database.grants].sort() } : {}),
|
|
186
203
|
...(app.database.migrationsSha256
|
|
187
204
|
? { migrationsSha256: app.database.migrationsSha256 }
|
|
@@ -189,6 +206,7 @@ export function wantedWorkerApp(org, entry, recorded, input) {
|
|
|
189
206
|
},
|
|
190
207
|
}
|
|
191
208
|
: {}),
|
|
209
|
+
...(crons.length > 0 ? { crons } : {}),
|
|
192
210
|
secrets: secrets.sort((a, b) => a.name.localeCompare(b.name)),
|
|
193
211
|
},
|
|
194
212
|
};
|
|
@@ -321,7 +339,7 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
321
339
|
block(`${wanted.missing.join(', ')} not recorded yet`);
|
|
322
340
|
continue;
|
|
323
341
|
}
|
|
324
|
-
const { want } = wanted;
|
|
342
|
+
const { want, unset } = wanted;
|
|
325
343
|
const stray = strayContainerImage(app, input.registryAccountId);
|
|
326
344
|
if (stray) {
|
|
327
345
|
block(input.registryAccountId === undefined
|
|
@@ -345,7 +363,9 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
345
363
|
? `its Access service token ${recordedIds[remoteDatabaseKeys(entry.key).undelivered]} was minted and its secret never held; delete the token in Cloudflare and clear the recorded id before minting again`
|
|
346
364
|
: !input.canHoldSecrets
|
|
347
365
|
? 'its database credentials need a held-key store that can put, and the host has none'
|
|
348
|
-
:
|
|
366
|
+
: want.database.noDelete?.length && !db.noDeleteCheck
|
|
367
|
+
? "its noDelete tables need a database port that reads the role's rights back (deletableTables), and the host's has none"
|
|
368
|
+
: null;
|
|
349
369
|
if (why) {
|
|
350
370
|
block(why);
|
|
351
371
|
continue;
|
|
@@ -381,7 +401,7 @@ export function planWorkerApps(org, recordedIds, input) {
|
|
|
381
401
|
desired: { mode: 'apply', ...want },
|
|
382
402
|
},
|
|
383
403
|
step: {
|
|
384
|
-
description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}`,
|
|
404
|
+
description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}${unset.length > 0 ? ` (no value is held yet for secret ${unset.join(', ')}: the Worker runs without ${unset.length === 1 ? 'it' : 'them'} until the company adds ${unset.length === 1 ? 'it' : 'them'}, then the next apply sets ${unset.length === 1 ? 'it' : 'them'})` : ''}`,
|
|
385
405
|
destroys: deletes.length > 0,
|
|
386
406
|
migrates: renames.length > 0,
|
|
387
407
|
},
|
|
@@ -465,6 +485,15 @@ export async function applyWorkerApp(deps, org, action, context) {
|
|
|
465
485
|
}
|
|
466
486
|
// 2. Secrets: before the route, so the Worker never serves without them.
|
|
467
487
|
await deliverSecrets(deps, org, entryKey, want, context, at, call);
|
|
488
|
+
// 2b. Cron triggers, once the script and its secrets are there. An empty list clears the ones a
|
|
489
|
+
// previous apply set; a Worker that never had any makes no call. Idempotent, so a crash here is
|
|
490
|
+
// repaired by the next apply (the applied marker is not written yet).
|
|
491
|
+
const crons = want.crons ?? [];
|
|
492
|
+
const hadCrons = (parseJson(recorded0[workerAppAppliedScopeKey(entryKey)], {}).crons ?? [])
|
|
493
|
+
.length > 0;
|
|
494
|
+
if (crons.length > 0 || hadCrons) {
|
|
495
|
+
await call('set the cron triggers', () => workers.setSchedules(want.name, crons));
|
|
496
|
+
}
|
|
468
497
|
// 3. Containers.
|
|
469
498
|
await syncContainers(deps, entryKey, want, context, at, call);
|
|
470
499
|
// 4. The record on the org's own zone, 5. the route.
|
|
@@ -532,6 +561,22 @@ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
|
|
|
532
561
|
if (delivered[secret.name] !== version)
|
|
533
562
|
generated.push({ name: secret.name, value, version });
|
|
534
563
|
}
|
|
564
|
+
// Held secrets: the company's value, read from the held-key store. The plan found each one held; a
|
|
565
|
+
// value gone since stops the step, and the error names the ref, never a value.
|
|
566
|
+
for (const secret of want.secrets) {
|
|
567
|
+
if (secret.source !== 'heldSecret')
|
|
568
|
+
continue;
|
|
569
|
+
const ref = heldEnvRef(org.id, entryKey, secret.name);
|
|
570
|
+
const value = await call('read a held secret', async () => heldKeys?.resolve(ref));
|
|
571
|
+
if (!value) {
|
|
572
|
+
throw new Error(`${at}: held secret ${secret.name} is no longer held (ref ${ref}); the next plan leaves it off the Worker`);
|
|
573
|
+
}
|
|
574
|
+
deps.secret?.(value);
|
|
575
|
+
const version = heldSecretVersion(value);
|
|
576
|
+
if (delivered[secret.name] !== version) {
|
|
577
|
+
generated.push({ name: secret.name, value, version });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
535
580
|
if (generated.length > 0) {
|
|
536
581
|
await call('deliver secrets', () => workers.setSecrets(want.name, generated));
|
|
537
582
|
for (const { name, version } of generated)
|
package/dist/plan/workspace.d.ts
CHANGED
|
@@ -37,8 +37,8 @@ export declare const peopleHash: (emails: readonly string[]) => string;
|
|
|
37
37
|
export declare function withoutDarkEntries(org: DeclaredOrg): DeclaredOrg;
|
|
38
38
|
/**
|
|
39
39
|
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
40
|
-
* executor deploy it like any other. The copy has no database
|
|
41
|
-
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
40
|
+
* executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
|
|
41
|
+
* sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
|
|
42
42
|
* workspaces are read from when `org` has had dark entries removed.
|
|
43
43
|
*/
|
|
44
44
|
export declare function withWorkspaceWorkers(org: DeclaredOrg, declared?: DeclaredOrg): DeclaredOrg;
|
package/dist/plan/workspace.js
CHANGED
|
@@ -67,8 +67,8 @@ export function withoutDarkEntries(org) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
70
|
-
* executor deploy it like any other. The copy has no database
|
|
71
|
-
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
70
|
+
* executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
|
|
71
|
+
* sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
|
|
72
72
|
* workspaces are read from when `org` has had dark entries removed.
|
|
73
73
|
*/
|
|
74
74
|
export function withWorkspaceWorkers(org, declared = org) {
|
|
@@ -79,7 +79,7 @@ export function withWorkspaceWorkers(org, declared = org) {
|
|
|
79
79
|
if (!entry.workspace || !entry.workerApp)
|
|
80
80
|
continue;
|
|
81
81
|
const { workspace: _w, live: _l, hostedName: _h, uses: _u, workerApp, ...rest } = entry;
|
|
82
|
-
const { database: _d, authEnv: _a, ...app } = workerApp;
|
|
82
|
+
const { database: _d, authEnv: _a, triggers: _t, ...app } = workerApp;
|
|
83
83
|
const copy = {
|
|
84
84
|
...rest,
|
|
85
85
|
key: `${entry.key}-work`,
|
package/dist/release/decide.d.ts
CHANGED
|
@@ -106,6 +106,8 @@ export interface Decision {
|
|
|
106
106
|
refused: string[];
|
|
107
107
|
/** The reason a `copy_prod_to_stage` request was refused here, for `bg.<id>.stagecopy`; else null. */
|
|
108
108
|
stageRefusal: string | null;
|
|
109
|
+
/** The requested actions that took effect, in order (the same objects as `input.actions`). */
|
|
110
|
+
applied: ReleaseAction[];
|
|
109
111
|
}
|
|
110
112
|
export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
|
|
111
113
|
/** The time a health read should cover: from the step or window start to now. */
|