@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
package/dist/release/types.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type WindowState = {
|
|
|
27
27
|
| {
|
|
28
28
|
kind: 'staff';
|
|
29
29
|
since: string;
|
|
30
|
+
}
|
|
31
|
+
/** A person held the step with `pause`. `since` is when the step began; `resume` returns to `step` with it. */
|
|
32
|
+
| {
|
|
33
|
+
kind: 'paused';
|
|
34
|
+
percent: number;
|
|
35
|
+
since: string;
|
|
30
36
|
};
|
|
31
37
|
export interface ReleaseSignal {
|
|
32
38
|
requests: number;
|
|
@@ -88,6 +94,12 @@ export type ReleaseAction = {
|
|
|
88
94
|
kind: 'approve_step';
|
|
89
95
|
} | {
|
|
90
96
|
kind: 'open';
|
|
97
|
+
} | {
|
|
98
|
+
kind: 'pause';
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'resume';
|
|
101
|
+
} | {
|
|
102
|
+
kind: 'step_now';
|
|
91
103
|
} | {
|
|
92
104
|
kind: 'deploy_stage';
|
|
93
105
|
ref: string;
|
|
@@ -96,6 +108,38 @@ export type ReleaseAction = {
|
|
|
96
108
|
} | {
|
|
97
109
|
kind: 'undo_stage_copy';
|
|
98
110
|
};
|
|
111
|
+
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail';
|
|
112
|
+
/** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
|
|
113
|
+
export interface ReleaseEvent {
|
|
114
|
+
at: string;
|
|
115
|
+
kind: ReleaseEventKind;
|
|
116
|
+
percent?: number;
|
|
117
|
+
/** Who asked, when a person did. */
|
|
118
|
+
actor?: string;
|
|
119
|
+
reason?: string;
|
|
120
|
+
release?: {
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
};
|
|
124
|
+
signal?: ReleaseSignal;
|
|
125
|
+
}
|
|
126
|
+
/** C2: what reporting counts for one color in one bucket. */
|
|
127
|
+
export interface ReleaseSeriesColor {
|
|
128
|
+
requests: number;
|
|
129
|
+
errors: number;
|
|
130
|
+
slow: number;
|
|
131
|
+
durationMsSum: number;
|
|
132
|
+
signIns: number;
|
|
133
|
+
signInFailures: number;
|
|
134
|
+
}
|
|
135
|
+
/** C2: reporting's `GET /api/reporting/release-series` body. */
|
|
136
|
+
export interface ReleaseSeries {
|
|
137
|
+
bucketMinutes: number;
|
|
138
|
+
buckets: Array<{
|
|
139
|
+
start: string;
|
|
140
|
+
colors: Record<Color, ReleaseSeriesColor>;
|
|
141
|
+
}>;
|
|
142
|
+
}
|
|
99
143
|
/** The requests that run a stage copy. They are handled by the tick's second phase, never by an apply. */
|
|
100
144
|
export declare const isStageCopyAction: (action: ReleaseAction) => boolean;
|
|
101
145
|
export declare const COLORS: readonly Color[];
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
2
|
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
3
|
import { hostingNames } from '../plan/locked-hostnames.js';
|
|
4
4
|
import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
|
|
5
5
|
import { validatePlatform } from '../platform.js';
|
|
6
|
-
import { releaseActionSchema } from '../release/state.js';
|
|
6
|
+
import { releaseActionSchema, releaseEventKind, releaseEventPayload, } from '../release/state.js';
|
|
7
7
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
8
8
|
import { validateSlug } from '../slug.js';
|
|
9
9
|
import { StaleLeaseError, } from '../store.js';
|
|
10
|
-
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
|
|
10
|
+
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseEvents, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
|
|
11
11
|
const UNIQUE_VIOLATION = '23505';
|
|
12
12
|
/**
|
|
13
13
|
* #441: runs `write`, and with a `fence` only if its token is still the
|
|
@@ -1100,5 +1100,31 @@ export function createPostgresReleaseStore(db) {
|
|
|
1100
1100
|
.set({ consumedAt: sql `now()` })
|
|
1101
1101
|
.where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.consumedAt)));
|
|
1102
1102
|
},
|
|
1103
|
+
async appendEvents({ orgId, entryKey, events }) {
|
|
1104
|
+
if (events.length === 0)
|
|
1105
|
+
return;
|
|
1106
|
+
await db.insert(foundryReleaseEvents).values(events.map(({ at, kind, ...payload }) => ({
|
|
1107
|
+
orgId,
|
|
1108
|
+
entryKey,
|
|
1109
|
+
at: new Date(at),
|
|
1110
|
+
kind,
|
|
1111
|
+
payload,
|
|
1112
|
+
})));
|
|
1113
|
+
},
|
|
1114
|
+
async readEvents({ orgId, entryKey, since, limit }) {
|
|
1115
|
+
const rows = await db
|
|
1116
|
+
.select()
|
|
1117
|
+
.from(foundryReleaseEvents)
|
|
1118
|
+
.where(and(eq(foundryReleaseEvents.orgId, orgId), eq(foundryReleaseEvents.entryKey, entryKey), ...(since ? [gte(foundryReleaseEvents.at, new Date(since))] : [])))
|
|
1119
|
+
.orderBy(desc(foundryReleaseEvents.at), desc(foundryReleaseEvents.id))
|
|
1120
|
+
.limit(limit);
|
|
1121
|
+
return rows.flatMap((row) => {
|
|
1122
|
+
const kind = releaseEventKind.safeParse(row.kind);
|
|
1123
|
+
const payload = releaseEventPayload.safeParse(row.payload);
|
|
1124
|
+
return kind.success && payload.success
|
|
1125
|
+
? [{ at: row.at.toISOString(), kind: kind.data, ...payload.data }]
|
|
1126
|
+
: [];
|
|
1127
|
+
});
|
|
1128
|
+
},
|
|
1103
1129
|
};
|
|
1104
1130
|
}
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -2854,6 +2854,118 @@ export declare const foundryReleaseRequests: import("drizzle-orm/pg-core").PgTab
|
|
|
2854
2854
|
};
|
|
2855
2855
|
dialect: "pg";
|
|
2856
2856
|
}>;
|
|
2857
|
+
/** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
|
|
2858
|
+
export declare const foundryReleaseEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2859
|
+
name: "foundry_release_events";
|
|
2860
|
+
schema: undefined;
|
|
2861
|
+
columns: {
|
|
2862
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
2863
|
+
name: "id";
|
|
2864
|
+
tableName: "foundry_release_events";
|
|
2865
|
+
dataType: "number";
|
|
2866
|
+
columnType: "PgBigSerial53";
|
|
2867
|
+
data: number;
|
|
2868
|
+
driverParam: number;
|
|
2869
|
+
notNull: true;
|
|
2870
|
+
hasDefault: true;
|
|
2871
|
+
isPrimaryKey: true;
|
|
2872
|
+
isAutoincrement: false;
|
|
2873
|
+
hasRuntimeDefault: false;
|
|
2874
|
+
enumValues: undefined;
|
|
2875
|
+
baseColumn: never;
|
|
2876
|
+
identity: undefined;
|
|
2877
|
+
generated: undefined;
|
|
2878
|
+
}, {}, {}>;
|
|
2879
|
+
orgId: import("drizzle-orm/pg-core").PgColumn<{
|
|
2880
|
+
name: "org_id";
|
|
2881
|
+
tableName: "foundry_release_events";
|
|
2882
|
+
dataType: "string";
|
|
2883
|
+
columnType: "PgText";
|
|
2884
|
+
data: string;
|
|
2885
|
+
driverParam: string;
|
|
2886
|
+
notNull: true;
|
|
2887
|
+
hasDefault: false;
|
|
2888
|
+
isPrimaryKey: false;
|
|
2889
|
+
isAutoincrement: false;
|
|
2890
|
+
hasRuntimeDefault: false;
|
|
2891
|
+
enumValues: [string, ...string[]];
|
|
2892
|
+
baseColumn: never;
|
|
2893
|
+
identity: undefined;
|
|
2894
|
+
generated: undefined;
|
|
2895
|
+
}, {}, {}>;
|
|
2896
|
+
entryKey: import("drizzle-orm/pg-core").PgColumn<{
|
|
2897
|
+
name: "entry_key";
|
|
2898
|
+
tableName: "foundry_release_events";
|
|
2899
|
+
dataType: "string";
|
|
2900
|
+
columnType: "PgText";
|
|
2901
|
+
data: string;
|
|
2902
|
+
driverParam: string;
|
|
2903
|
+
notNull: true;
|
|
2904
|
+
hasDefault: false;
|
|
2905
|
+
isPrimaryKey: false;
|
|
2906
|
+
isAutoincrement: false;
|
|
2907
|
+
hasRuntimeDefault: false;
|
|
2908
|
+
enumValues: [string, ...string[]];
|
|
2909
|
+
baseColumn: never;
|
|
2910
|
+
identity: undefined;
|
|
2911
|
+
generated: undefined;
|
|
2912
|
+
}, {}, {}>;
|
|
2913
|
+
at: import("drizzle-orm/pg-core").PgColumn<{
|
|
2914
|
+
name: "at";
|
|
2915
|
+
tableName: "foundry_release_events";
|
|
2916
|
+
dataType: "date";
|
|
2917
|
+
columnType: "PgTimestamp";
|
|
2918
|
+
data: Date;
|
|
2919
|
+
driverParam: string;
|
|
2920
|
+
notNull: true;
|
|
2921
|
+
hasDefault: false;
|
|
2922
|
+
isPrimaryKey: false;
|
|
2923
|
+
isAutoincrement: false;
|
|
2924
|
+
hasRuntimeDefault: false;
|
|
2925
|
+
enumValues: undefined;
|
|
2926
|
+
baseColumn: never;
|
|
2927
|
+
identity: undefined;
|
|
2928
|
+
generated: undefined;
|
|
2929
|
+
}, {}, {}>;
|
|
2930
|
+
kind: import("drizzle-orm/pg-core").PgColumn<{
|
|
2931
|
+
name: "kind";
|
|
2932
|
+
tableName: "foundry_release_events";
|
|
2933
|
+
dataType: "string";
|
|
2934
|
+
columnType: "PgText";
|
|
2935
|
+
data: string;
|
|
2936
|
+
driverParam: string;
|
|
2937
|
+
notNull: true;
|
|
2938
|
+
hasDefault: false;
|
|
2939
|
+
isPrimaryKey: false;
|
|
2940
|
+
isAutoincrement: false;
|
|
2941
|
+
hasRuntimeDefault: false;
|
|
2942
|
+
enumValues: [string, ...string[]];
|
|
2943
|
+
baseColumn: never;
|
|
2944
|
+
identity: undefined;
|
|
2945
|
+
generated: undefined;
|
|
2946
|
+
}, {}, {}>;
|
|
2947
|
+
payload: import("drizzle-orm/pg-core").PgColumn<{
|
|
2948
|
+
name: "payload";
|
|
2949
|
+
tableName: "foundry_release_events";
|
|
2950
|
+
dataType: "json";
|
|
2951
|
+
columnType: "PgJsonb";
|
|
2952
|
+
data: Record<string, unknown>;
|
|
2953
|
+
driverParam: unknown;
|
|
2954
|
+
notNull: true;
|
|
2955
|
+
hasDefault: true;
|
|
2956
|
+
isPrimaryKey: false;
|
|
2957
|
+
isAutoincrement: false;
|
|
2958
|
+
hasRuntimeDefault: false;
|
|
2959
|
+
enumValues: undefined;
|
|
2960
|
+
baseColumn: never;
|
|
2961
|
+
identity: undefined;
|
|
2962
|
+
generated: undefined;
|
|
2963
|
+
}, {}, {
|
|
2964
|
+
$type: Record<string, unknown>;
|
|
2965
|
+
}>;
|
|
2966
|
+
};
|
|
2967
|
+
dialect: "pg";
|
|
2968
|
+
}>;
|
|
2857
2969
|
export type DeclaredOrgRow = typeof declaredOrgs.$inferSelect;
|
|
2858
2970
|
export type NewDeclaredOrgRow = typeof declaredOrgs.$inferInsert;
|
|
2859
2971
|
export type AppliedStateRow = typeof foundryAppliedState.$inferSelect;
|
package/dist/store/schema.js
CHANGED
|
@@ -268,3 +268,12 @@ export const foundryReleaseRequests = pgTable('foundry_release_requests', {
|
|
|
268
268
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
269
269
|
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
|
270
270
|
});
|
|
271
|
+
/** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
|
|
272
|
+
export const foundryReleaseEvents = pgTable('foundry_release_events', {
|
|
273
|
+
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
|
274
|
+
orgId: text('org_id').notNull(),
|
|
275
|
+
entryKey: text('entry_key').notNull(),
|
|
276
|
+
at: timestamp('at', { withTimezone: true }).notNull(),
|
|
277
|
+
kind: text('kind').notNull(),
|
|
278
|
+
payload: jsonb('payload').notNull().$type().default({}),
|
|
279
|
+
});
|
|
@@ -43,12 +43,28 @@ export interface WorkerAppConfig {
|
|
|
43
43
|
* `KEYS_CREDENTIAL`, at most one) delivers a keys custody Worker credential
|
|
44
44
|
* for the app's own host id (`workerAppHostId`), with `KEYS_URL` as a var.
|
|
45
45
|
* `keys` (an issued API key) is refused: custody does not accept one.
|
|
46
|
+
* `heldSecret` is a value the company supplies (a webhook address, a vendor
|
|
47
|
+
* key): it is read from the held-key store at `heldEnvRef(orgId, entryKey, name)`,
|
|
48
|
+
* the ref a `heldSecret` env of a Coolify entry uses, and set on the Worker
|
|
49
|
+
* only while a value is held. With no value the Worker deploys without the
|
|
50
|
+
* secret and the plan says so.
|
|
46
51
|
*/
|
|
47
52
|
secrets?: Record<string, {
|
|
48
53
|
from: 'generated';
|
|
49
54
|
} | {
|
|
50
55
|
from: 'keysCustody';
|
|
56
|
+
} | {
|
|
57
|
+
from: 'heldSecret';
|
|
51
58
|
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Cloudflare cron triggers: at most three standard five-field cron
|
|
61
|
+
* expressions (minute hour day-of-month month day-of-week, UTC), each
|
|
62
|
+
* once. The Worker's `scheduled` handler receives them. Empty or absent
|
|
63
|
+
* clears the triggers.
|
|
64
|
+
*/
|
|
65
|
+
triggers?: {
|
|
66
|
+
crons?: string[];
|
|
67
|
+
};
|
|
52
68
|
/** True delivers the entry's sign-in env as Worker vars. Needs `uses.auth`. */
|
|
53
69
|
authEnv?: boolean;
|
|
54
70
|
/**
|
|
@@ -64,9 +80,14 @@ export interface WorkerAppConfig {
|
|
|
64
80
|
* functions the runtime role may execute, as `name(type, ...)` or
|
|
65
81
|
* `schema.name(type, ...)`: the security definer functions a migration
|
|
66
82
|
* revoked from public, such as `@wtfalch/audit`'s ledger functions.
|
|
83
|
+
* `noDelete` names tables the runtime role may select, insert into and
|
|
84
|
+
* update but never delete from or truncate (a looser `appendOnly`: a table
|
|
85
|
+
* in both lists is refused). The app removes rows from them through a
|
|
86
|
+
* `grants` function, as a retention job does.
|
|
67
87
|
*/
|
|
68
88
|
database?: {
|
|
69
89
|
appendOnly?: string[];
|
|
90
|
+
noDelete?: string[];
|
|
70
91
|
grants?: string[];
|
|
71
92
|
migrationsSha256?: string;
|
|
72
93
|
};
|
|
@@ -80,7 +101,7 @@ export declare const WORKER_APP_COMPATIBILITY_FLAGS: ReadonlySet<string>;
|
|
|
80
101
|
export declare const WORKER_APP_INSTANCE_TYPES: ReadonlySet<string>;
|
|
81
102
|
/** The most instances one container class may scale to. */
|
|
82
103
|
export declare const WORKER_APP_MAX_INSTANCES = 100;
|
|
83
|
-
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
104
|
+
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
84
105
|
export declare const WORKER_APP_KEYS_URL_VAR = "KEYS_URL";
|
|
85
106
|
/** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
|
|
86
107
|
export declare const entryHasDatabase: (entry: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
2
|
+
import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, workerAppCronProblem, } from './entry-rules.js';
|
|
3
3
|
import { validateSlug } from './slug.js';
|
|
4
4
|
/** The compatibility flags a Worker app may ask for. */
|
|
5
5
|
export const WORKER_APP_COMPATIBILITY_FLAGS = new Set(['nodejs_compat']);
|
|
@@ -13,7 +13,7 @@ export const WORKER_APP_INSTANCE_TYPES = new Set([
|
|
|
13
13
|
]);
|
|
14
14
|
/** The most instances one container class may scale to. */
|
|
15
15
|
export const WORKER_APP_MAX_INSTANCES = 100;
|
|
16
|
-
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
16
|
+
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
17
17
|
export const WORKER_APP_KEYS_URL_VAR = 'KEYS_URL';
|
|
18
18
|
/** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
|
|
19
19
|
export const entryHasDatabase = (entry) => entry.database || Boolean(entry.workerApp?.database);
|
|
@@ -52,6 +52,9 @@ const MIGRATION_TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
52
52
|
const IMAGE_REPOSITORY = /^[a-z0-9]+([._-][a-z0-9]+)*(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+$/;
|
|
53
53
|
const COMPAT_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
54
54
|
const VAR_MAX_LENGTH = 1000;
|
|
55
|
+
/** `heldEnvRef` in `org.ts`: the ref is at most 128 characters and 52 of them are fixed. */
|
|
56
|
+
const HELD_REF_MAX = 128;
|
|
57
|
+
const HELD_REF_FIXED = 52;
|
|
55
58
|
/**
|
|
56
59
|
* Checks an artifact reference: an https URL with no userinfo, query or
|
|
57
60
|
* fragment (those end up verbatim in recorded scope ids) and a lowercase hex
|
|
@@ -278,6 +281,16 @@ export function validateEntryWorkerApp(entry, hosting, names) {
|
|
|
278
281
|
return invalid;
|
|
279
282
|
if (source?.from === 'generated')
|
|
280
283
|
continue;
|
|
284
|
+
if (source?.from === 'heldSecret') {
|
|
285
|
+
if (name === WORKER_APP_KEYS_SECRET) {
|
|
286
|
+
return `${at} secret '${name}' is the custody credential: declare it as keysCustody, not heldSecret`;
|
|
287
|
+
}
|
|
288
|
+
// `heldEnvRef`'s own limit (org.ts restates the ref: this file cannot import it).
|
|
289
|
+
if (HELD_REF_FIXED + entry.key.length + name.length > HELD_REF_MAX) {
|
|
290
|
+
return `${at} secret '${name}' heldSecret: the name and the entry key are too long to hold a value under (at most ${HELD_REF_MAX - HELD_REF_FIXED} characters together)`;
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
281
294
|
if (source?.from !== 'keysCustody')
|
|
282
295
|
return `${at} secret '${name}' has an unknown source`;
|
|
283
296
|
keysSecrets++;
|
|
@@ -312,6 +325,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
|
|
|
312
325
|
// Not a URL: nothing to check.
|
|
313
326
|
}
|
|
314
327
|
}
|
|
328
|
+
const invalidTriggers = validateWorkerAppTriggers(at, app);
|
|
329
|
+
if (invalidTriggers)
|
|
330
|
+
return invalidTriggers;
|
|
315
331
|
const invalidDatabase = validateWorkerAppDatabase(at, app);
|
|
316
332
|
if (invalidDatabase)
|
|
317
333
|
return invalidDatabase;
|
|
@@ -332,11 +348,11 @@ function validateWorkerAppDatabase(at, app) {
|
|
|
332
348
|
if (database === undefined)
|
|
333
349
|
return null;
|
|
334
350
|
if (typeof database !== 'object' || database === null || Array.isArray(database)) {
|
|
335
|
-
return `${at} database must be an object: { appendOnly?: string[]; grants?: string[]; migrationsSha256?: string }`;
|
|
351
|
+
return `${at} database must be an object: { appendOnly?: string[]; noDelete?: string[]; grants?: string[]; migrationsSha256?: string }`;
|
|
336
352
|
}
|
|
337
|
-
const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'grants' && key !== 'migrationsSha256');
|
|
353
|
+
const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'noDelete' && key !== 'grants' && key !== 'migrationsSha256');
|
|
338
354
|
if (unknown) {
|
|
339
|
-
return `${at} database.${unknown} is not a setting: only appendOnly, grants and migrationsSha256`;
|
|
355
|
+
return `${at} database.${unknown} is not a setting: only appendOnly, noDelete, grants and migrationsSha256`;
|
|
340
356
|
}
|
|
341
357
|
const grants = database.grants;
|
|
342
358
|
if (grants !== undefined) {
|
|
@@ -357,21 +373,63 @@ function validateWorkerAppDatabase(at, app) {
|
|
|
357
373
|
if (sha !== undefined && (typeof sha !== 'string' || !SHA256_HEX.test(sha))) {
|
|
358
374
|
return `${at} database.migrationsSha256 must be 64 lowercase hex characters`;
|
|
359
375
|
}
|
|
360
|
-
const appendOnly = database
|
|
361
|
-
if (appendOnly ===
|
|
362
|
-
return
|
|
363
|
-
|
|
364
|
-
|
|
376
|
+
const appendOnly = tableList(at, 'appendOnly', WORKER_APP_APPEND_ONLY_MAX, database);
|
|
377
|
+
if (typeof appendOnly === 'string')
|
|
378
|
+
return appendOnly;
|
|
379
|
+
const noDelete = tableList(at, 'noDelete', WORKER_APP_NO_DELETE_MAX, database);
|
|
380
|
+
if (typeof noDelete === 'string')
|
|
381
|
+
return noDelete;
|
|
382
|
+
const both = noDelete.find((table) => appendOnly.includes(table));
|
|
383
|
+
if (both) {
|
|
384
|
+
return `${at} database.noDelete names '${both}', which database.appendOnly already covers (append-only is stricter): name it in one list`;
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
/** A table list (`appendOnly` or `noDelete`) of `database`: its names, or the refusal. */
|
|
389
|
+
function tableList(at, key, max, database) {
|
|
390
|
+
const list = database[key];
|
|
391
|
+
if (list === undefined)
|
|
392
|
+
return [];
|
|
393
|
+
if (!Array.isArray(list) || list.length > max) {
|
|
394
|
+
return `${at} database.${key} must be a list of at most ${max} table names`;
|
|
365
395
|
}
|
|
366
396
|
const seen = new Set();
|
|
367
|
-
for (const table of
|
|
397
|
+
for (const table of list) {
|
|
368
398
|
if (typeof table !== 'string' || !TABLE_NAME.test(table)) {
|
|
369
|
-
return `${at} database
|
|
399
|
+
return `${at} database.${key} names '${String(table).slice(0, 80)}', which is not a table name (letters, digits and '_', optionally schema.table)`;
|
|
370
400
|
}
|
|
371
401
|
if (seen.has(table))
|
|
372
|
-
return `${at} database
|
|
402
|
+
return `${at} database.${key} names '${table}' twice`;
|
|
373
403
|
seen.add(table);
|
|
374
404
|
}
|
|
405
|
+
return [...seen];
|
|
406
|
+
}
|
|
407
|
+
/** `triggers`: only `crons`, at most {@link WORKER_APP_CRONS_MAX}, each a standard five-field cron, each once. */
|
|
408
|
+
function validateWorkerAppTriggers(at, app) {
|
|
409
|
+
const triggers = app.triggers;
|
|
410
|
+
if (triggers === undefined)
|
|
411
|
+
return null;
|
|
412
|
+
if (typeof triggers !== 'object' || triggers === null || Array.isArray(triggers)) {
|
|
413
|
+
return `${at} triggers must be an object: { crons?: string[] }`;
|
|
414
|
+
}
|
|
415
|
+
const unknown = Object.keys(triggers).find((key) => key !== 'crons');
|
|
416
|
+
if (unknown)
|
|
417
|
+
return `${at} triggers.${unknown} is not a trigger: only crons`;
|
|
418
|
+
const crons = triggers.crons;
|
|
419
|
+
if (crons === undefined)
|
|
420
|
+
return null;
|
|
421
|
+
if (!Array.isArray(crons) || crons.length > WORKER_APP_CRONS_MAX) {
|
|
422
|
+
return `${at} triggers.crons must be a list of at most ${WORKER_APP_CRONS_MAX} cron expressions (Cloudflare's limit for one Worker)`;
|
|
423
|
+
}
|
|
424
|
+
const seen = new Set();
|
|
425
|
+
for (const cron of crons) {
|
|
426
|
+
const problem = workerAppCronProblem(cron);
|
|
427
|
+
if (problem)
|
|
428
|
+
return `${at} triggers.crons: ${problem}`;
|
|
429
|
+
if (seen.has(cron))
|
|
430
|
+
return `${at} triggers.crons names '${cron}' twice`;
|
|
431
|
+
seen.add(cron);
|
|
432
|
+
}
|
|
375
433
|
return null;
|
|
376
434
|
}
|
|
377
435
|
function validateDurableObjects(at, app, claim) {
|
|
@@ -20,8 +20,10 @@ export interface WorkerAppMigrationRunner<O> {
|
|
|
20
20
|
applyMigration(owner: O, migration: DatabaseMigration): Promise<void>;
|
|
21
21
|
/**
|
|
22
22
|
* Creates or updates the `runtime.user` role with `runtime.password`, grants it DML on the
|
|
23
|
-
* tables, revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table,
|
|
24
|
-
*
|
|
23
|
+
* tables, revokes UPDATE, DELETE and TRUNCATE from it on each `appendOnly` table, revokes
|
|
24
|
+
* DELETE and TRUNCATE on each `noDelete` table (`@wtfalch/db`'s `ensureRuntimeRole` takes the
|
|
25
|
+
* same two lists by those names), and grants it EXECUTE on each `grants` function. A runner
|
|
26
|
+
* that drops `noDelete` leaves the role able to delete: pass it on.
|
|
25
27
|
*/
|
|
26
28
|
ensureRuntimeRole(input: {
|
|
27
29
|
owner: O;
|
|
@@ -30,6 +32,7 @@ export interface WorkerAppMigrationRunner<O> {
|
|
|
30
32
|
password: string;
|
|
31
33
|
};
|
|
32
34
|
appendOnly: string[];
|
|
35
|
+
noDelete: string[];
|
|
33
36
|
grants: string[];
|
|
34
37
|
}): Promise<void>;
|
|
35
38
|
}
|
|
@@ -80,6 +83,7 @@ export declare function pendingPlatformMigrations(platform: readonly PlatformMig
|
|
|
80
83
|
*/
|
|
81
84
|
export declare function runWorkerAppMigrations<O>(runner: WorkerAppMigrationRunner<O>, owner: O, text: string | null, want: {
|
|
82
85
|
appendOnly: readonly string[];
|
|
86
|
+
noDelete?: readonly string[];
|
|
83
87
|
grants?: readonly string[];
|
|
84
88
|
migrationsSha256?: string;
|
|
85
89
|
}, runtime: {
|
|
@@ -160,6 +160,7 @@ export async function runWorkerAppMigrations(runner, owner, text, want, runtime,
|
|
|
160
160
|
owner,
|
|
161
161
|
runtime,
|
|
162
162
|
appendOnly: [...want.appendOnly],
|
|
163
|
+
noDelete: [...(want.noDelete ?? [])],
|
|
163
164
|
grants: [...(want.grants ?? [])],
|
|
164
165
|
}));
|
|
165
166
|
return { applied: done, ran, skipped: platform.skipped };
|