lakebed 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/package.json +1 -1
- package/src/anonymous-server.js +1858 -126
- package/src/anonymous.js +25 -5
- package/src/cli.js +79 -1
- package/src/source-runtime.js +2 -1
- package/src/version.js +1 -1
package/src/anonymous-server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { domainToASCII } from "node:url";
|
|
3
5
|
import {
|
|
4
6
|
createClaimToken,
|
|
5
7
|
createDeployId,
|
|
@@ -165,6 +167,106 @@ function normalizeAppBaseDomain(value) {
|
|
|
165
167
|
.toLowerCase();
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
const reservedLakebedSubdomainLabels = new Set([
|
|
171
|
+
"admin",
|
|
172
|
+
"api",
|
|
173
|
+
"app",
|
|
174
|
+
"assets",
|
|
175
|
+
"auth",
|
|
176
|
+
"billing",
|
|
177
|
+
"cdn",
|
|
178
|
+
"cname",
|
|
179
|
+
"console",
|
|
180
|
+
"dashboard",
|
|
181
|
+
"docs",
|
|
182
|
+
"ftp",
|
|
183
|
+
"imap",
|
|
184
|
+
"login",
|
|
185
|
+
"mail",
|
|
186
|
+
"ns1",
|
|
187
|
+
"ns2",
|
|
188
|
+
"origin",
|
|
189
|
+
"pop",
|
|
190
|
+
"proxy-fallback",
|
|
191
|
+
"smtp",
|
|
192
|
+
"static",
|
|
193
|
+
"status",
|
|
194
|
+
"support",
|
|
195
|
+
"www"
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
function normalizeHostname(value) {
|
|
199
|
+
const trimmed = String(value ?? "")
|
|
200
|
+
.trim()
|
|
201
|
+
.replace(/\.$/, "")
|
|
202
|
+
.toLowerCase();
|
|
203
|
+
if (!trimmed) {
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return domainToASCII(trimmed).toLowerCase();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hostnameFromHost(host) {
|
|
211
|
+
const value = String(host ?? "").trim();
|
|
212
|
+
if (value.startsWith("[")) {
|
|
213
|
+
const end = value.indexOf("]");
|
|
214
|
+
return normalizeHostname(end === -1 ? value : value.slice(1, end));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return normalizeHostname(value.split(":")[0]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateLakebedSubdomainLabel(label) {
|
|
221
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) {
|
|
222
|
+
throw new Error("Lakebed subdomains must use one DNS label with letters, numbers, or hyphens.");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (reservedLakebedSubdomainLabels.has(label)) {
|
|
226
|
+
throw new Error(`The subdomain ${label} is reserved for Lakebed.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizeLakebedSubdomainInput(value, appBaseDomain) {
|
|
231
|
+
const baseDomain = normalizeHostname(appBaseDomain);
|
|
232
|
+
if (!baseDomain) {
|
|
233
|
+
throw new Error("Lakebed app subdomains are not configured on this runner.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const raw = String(value ?? "").trim();
|
|
237
|
+
if (!raw) {
|
|
238
|
+
throw new Error("Subdomain is required.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes("/") || raw.includes("\\") || raw.includes(":")) {
|
|
242
|
+
throw new Error("Enter a subdomain like my-app.lakebed.app, without a scheme, port, or path.");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const hostname = normalizeHostname(raw);
|
|
246
|
+
if (!hostname) {
|
|
247
|
+
throw new Error("Subdomain is not a valid hostname.");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const suffix = `.${baseDomain}`;
|
|
251
|
+
let label;
|
|
252
|
+
if (hostname.endsWith(suffix)) {
|
|
253
|
+
label = hostname.slice(0, -suffix.length);
|
|
254
|
+
if (!label || label.includes(".")) {
|
|
255
|
+
throw new Error(`Lakebed subdomains must be exactly one label under ${baseDomain}.`);
|
|
256
|
+
}
|
|
257
|
+
} else if (!hostname.includes(".")) {
|
|
258
|
+
label = hostname;
|
|
259
|
+
} else {
|
|
260
|
+
throw new Error(`Lakebed subdomains must end with ${baseDomain}.`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
validateLakebedSubdomainLabel(label);
|
|
264
|
+
return {
|
|
265
|
+
hostname: `${label}.${baseDomain}`,
|
|
266
|
+
label
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
168
270
|
function appUrlForSlug({ appBaseDomain, publicRootUrl, slug }) {
|
|
169
271
|
if (appBaseDomain) {
|
|
170
272
|
return `https://${slug}.${appBaseDomain}`;
|
|
@@ -200,6 +302,19 @@ function responseForDeploy({ deploy, token }) {
|
|
|
200
302
|
};
|
|
201
303
|
}
|
|
202
304
|
|
|
305
|
+
function responseForDeployDomain(domain) {
|
|
306
|
+
return {
|
|
307
|
+
createdAt: domain.createdAt,
|
|
308
|
+
deployId: domain.deployId,
|
|
309
|
+
hostname: domain.hostname,
|
|
310
|
+
kind: domain.kind,
|
|
311
|
+
primary: Boolean(domain.isPrimary),
|
|
312
|
+
status: domain.status,
|
|
313
|
+
updatedAt: domain.updatedAt,
|
|
314
|
+
url: domain.url
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
203
318
|
function isExpired(deploy) {
|
|
204
319
|
if (deploy?.ownerId) {
|
|
205
320
|
return false;
|
|
@@ -240,14 +355,14 @@ function parseHostDeploy({ appBaseDomain, host, url }) {
|
|
|
240
355
|
return null;
|
|
241
356
|
}
|
|
242
357
|
|
|
243
|
-
const hostname = host
|
|
358
|
+
const hostname = hostnameFromHost(host);
|
|
244
359
|
const suffix = `.${appBaseDomain.toLowerCase()}`;
|
|
245
360
|
if (!hostname.endsWith(suffix)) {
|
|
246
361
|
return null;
|
|
247
362
|
}
|
|
248
363
|
|
|
249
364
|
const slug = hostname.slice(0, -suffix.length);
|
|
250
|
-
if (!slug || slug
|
|
365
|
+
if (!slug || slug.includes(".") || reservedLakebedSubdomainLabels.has(slug)) {
|
|
251
366
|
return null;
|
|
252
367
|
}
|
|
253
368
|
|
|
@@ -266,6 +381,138 @@ function quotaLimitForBucket(bucket, deploy) {
|
|
|
266
381
|
return deploy.limits.requestsPerDay;
|
|
267
382
|
}
|
|
268
383
|
|
|
384
|
+
function clientTrafficQuotaBucket(bucket) {
|
|
385
|
+
return bucket === "mutations" ? "anonymous_client_mutations" : "anonymous_client_requests";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function clientTrafficQuotaSuggestion(bucket) {
|
|
389
|
+
return bucket === "mutations"
|
|
390
|
+
? "Retry after reset or reduce mutation frequency from this client."
|
|
391
|
+
: "Retry after reset or reduce request frequency from this client.";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function anonymousDeployCreationPolicy({ env = process.env, publicRootUrl }) {
|
|
395
|
+
const production = !isLocalPublicRootUrl(publicRootUrl);
|
|
396
|
+
const disabled =
|
|
397
|
+
booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_DISABLED) ||
|
|
398
|
+
booleanFromEnv(env.LAKEBED_ANONYMOUS_DEPLOYS_DISABLED);
|
|
399
|
+
const defaultPerClient = production ? 50 : Number.POSITIVE_INFINITY;
|
|
400
|
+
const defaultGlobal = production ? 5000 : Number.POSITIVE_INFINITY;
|
|
401
|
+
return {
|
|
402
|
+
disabled,
|
|
403
|
+
globalLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_GLOBAL_PER_DAY, defaultGlobal),
|
|
404
|
+
perClientLimit: positiveIntegerLimitFromEnv(env.LAKEBED_ANONYMOUS_DEPLOY_CREATE_PER_CLIENT_PER_DAY, defaultPerClient),
|
|
405
|
+
production
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function anonymousClientTrafficPolicy({ env = process.env, publicRootUrl }) {
|
|
410
|
+
const production = !isLocalPublicRootUrl(publicRootUrl);
|
|
411
|
+
return {
|
|
412
|
+
mutationsPerClientLimit: positiveIntegerLimitFromEnv(
|
|
413
|
+
env.LAKEBED_ANONYMOUS_MUTATIONS_PER_CLIENT_PER_DAY,
|
|
414
|
+
production ? 10000 : Number.POSITIVE_INFINITY
|
|
415
|
+
),
|
|
416
|
+
requestsPerClientLimit: positiveIntegerLimitFromEnv(
|
|
417
|
+
env.LAKEBED_ANONYMOUS_REQUESTS_PER_CLIENT_PER_DAY,
|
|
418
|
+
production ? 100000 : Number.POSITIVE_INFINITY
|
|
419
|
+
)
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function cleanupPolicyFromEnv(env = process.env) {
|
|
424
|
+
return {
|
|
425
|
+
graceSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_GRACE, 60 * 60, { min: 0 }),
|
|
426
|
+
intervalSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_INTERVAL, 60 * 60, { min: 60 }),
|
|
427
|
+
retentionSeconds: durationSecondsFromEnv(env.LAKEBED_ANONYMOUS_CLEANUP_RETENTION, 7 * 24 * 60 * 60, { min: 0 })
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function normalizePeerAddress(value) {
|
|
432
|
+
let address = String(value ?? "").trim();
|
|
433
|
+
if (!address) {
|
|
434
|
+
return "unknown";
|
|
435
|
+
}
|
|
436
|
+
if (address.startsWith("::ffff:")) {
|
|
437
|
+
address = address.slice("::ffff:".length);
|
|
438
|
+
}
|
|
439
|
+
if (address.startsWith("[") && address.includes("]")) {
|
|
440
|
+
address = address.slice(1, address.indexOf("]"));
|
|
441
|
+
}
|
|
442
|
+
if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(address)) {
|
|
443
|
+
address = address.slice(0, address.lastIndexOf(":"));
|
|
444
|
+
}
|
|
445
|
+
return address;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function ipv4ToNumber(address) {
|
|
449
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
450
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
return parts.reduce((acc, part) => (acc << 8) + part, 0) >>> 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ipv4InCidr(address, cidr) {
|
|
457
|
+
const [base, rawBits] = cidr.split("/");
|
|
458
|
+
const bits = Number(rawBits);
|
|
459
|
+
const value = ipv4ToNumber(address);
|
|
460
|
+
const baseValue = ipv4ToNumber(base);
|
|
461
|
+
if (value === null || baseValue === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
466
|
+
return (value & mask) === (baseValue & mask);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function hasRailwayRuntimeEnv(env = process.env) {
|
|
470
|
+
return Boolean(env.RAILWAY_SERVICE_ID || env.RAILWAY_PROJECT_ID || env.RAILWAY_DEPLOYMENT_ID || env.RAILWAY_REPLICA_ID);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isRailwayProxyPeer(req, env = process.env) {
|
|
474
|
+
const edge = String(req.headers["x-railway-edge"] ?? "");
|
|
475
|
+
if (!hasRailwayRuntimeEnv(env) || !/^railway\/[a-z0-9][a-z0-9-]*$/i.test(edge)) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Railway's proxy guidance treats 100.0.0.0/8 as the trusted internal proxy range.
|
|
480
|
+
const address = normalizePeerAddress(req.socket.remoteAddress);
|
|
481
|
+
return isIP(address) === 4 && ipv4InCidr(address, "100.0.0.0/8");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function shouldTrustProxyHeaders(req, env = process.env) {
|
|
485
|
+
if (booleanFromEnv(env.LAKEBED_TRUST_PROXY_HEADERS)) {
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return isRailwayProxyPeer(req, env);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function firstHeaderValue(req, names) {
|
|
493
|
+
for (const name of names) {
|
|
494
|
+
const raw = req.headers[name];
|
|
495
|
+
const candidate = String(Array.isArray(raw) ? raw[0] : (raw ?? ""))
|
|
496
|
+
.split(",")[0]
|
|
497
|
+
.trim();
|
|
498
|
+
if (candidate) {
|
|
499
|
+
return normalizePeerAddress(candidate).slice(0, 256);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return "";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function forwardedClientKey(req, env = process.env) {
|
|
506
|
+
if (shouldTrustProxyHeaders(req, env)) {
|
|
507
|
+
const forwarded = firstHeaderValue(req, ["x-real-ip", "cf-connecting-ip", "fly-client-ip", "x-forwarded-for"]);
|
|
508
|
+
if (forwarded) {
|
|
509
|
+
return forwarded;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return normalizePeerAddress(req.socket.remoteAddress).slice(0, 256);
|
|
514
|
+
}
|
|
515
|
+
|
|
269
516
|
const USER_LIMIT_OVERRIDE_KEYS = ["requestsPerDay", "mutationsPerDay"];
|
|
270
517
|
const USER_BOOST_MULTIPLIER = 20;
|
|
271
518
|
|
|
@@ -665,6 +912,193 @@ function bytesOfJson(value) {
|
|
|
665
912
|
return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
|
|
666
913
|
}
|
|
667
914
|
|
|
915
|
+
function durationSecondsFromEnv(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
|
|
916
|
+
if (value === undefined || value === null || value === "") {
|
|
917
|
+
return fallback;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const match = String(value).trim().match(/^(\d+)([smhd])?$/);
|
|
921
|
+
if (!match) {
|
|
922
|
+
throw new Error(`Invalid duration: ${value}. Use a value like 15m, 1h, 7d, or 604800.`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const multipliers = { d: 86400, h: 3600, m: 60, s: 1 };
|
|
926
|
+
const seconds = Number(match[1]) * multipliers[match[2] ?? "s"];
|
|
927
|
+
return Math.max(min, Math.min(max, seconds));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function positiveIntegerLimitFromEnv(value, fallback) {
|
|
931
|
+
if (value === undefined || value === null || value === "") {
|
|
932
|
+
return fallback;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const parsed = Number(value);
|
|
936
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
937
|
+
throw new Error(`Invalid limit: ${value}. Use a non-negative integer.`);
|
|
938
|
+
}
|
|
939
|
+
return parsed;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function isLocalPublicRootUrl(publicRootUrl) {
|
|
943
|
+
try {
|
|
944
|
+
const hostname = new URL(publicRootUrl).hostname.toLowerCase();
|
|
945
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
|
|
946
|
+
} catch {
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function booleanFromEnv(value) {
|
|
952
|
+
return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function quotaResetAt(windowStart) {
|
|
956
|
+
return new Date(Date.parse(windowStart) + 24 * 60 * 60 * 1000).toISOString();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function quotaRetryAfterSeconds(windowStart) {
|
|
960
|
+
return Math.max(1, Math.ceil((Date.parse(quotaResetAt(windowStart)) - Date.now()) / 1000));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export class LakebedQuotaError extends Error {
|
|
964
|
+
constructor({ bucket, count, limit, resetAt, retryAfterSeconds, status = 429, suggestion }) {
|
|
965
|
+
super(`${bucket} quota exceeded. Limit: ${limit}.`);
|
|
966
|
+
this.name = "LakebedQuotaError";
|
|
967
|
+
this.bucket = bucket;
|
|
968
|
+
this.code = "lakebed_quota_exceeded";
|
|
969
|
+
this.count = count;
|
|
970
|
+
this.limit = limit;
|
|
971
|
+
this.resetAt = resetAt ?? null;
|
|
972
|
+
this.retryAfterSeconds = retryAfterSeconds ?? null;
|
|
973
|
+
this.status = status;
|
|
974
|
+
this.suggestion = suggestion;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function isQuotaError(error) {
|
|
979
|
+
return error instanceof LakebedQuotaError || error?.code === "lakebed_quota_exceeded";
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function quotaErrorBody(error, extra = {}) {
|
|
983
|
+
return {
|
|
984
|
+
bucket: error.bucket,
|
|
985
|
+
code: error.code ?? "lakebed_quota_exceeded",
|
|
986
|
+
current: error.count,
|
|
987
|
+
error: error.message,
|
|
988
|
+
limit: error.limit,
|
|
989
|
+
resetAt: error.resetAt ?? null,
|
|
990
|
+
retryAfterSeconds: error.retryAfterSeconds ?? null,
|
|
991
|
+
suggestion: error.suggestion,
|
|
992
|
+
...extra
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function quotaErrorHeaders(error) {
|
|
997
|
+
const headers = {};
|
|
998
|
+
if (Number.isFinite(error.retryAfterSeconds)) {
|
|
999
|
+
headers["Retry-After"] = String(error.retryAfterSeconds);
|
|
1000
|
+
}
|
|
1001
|
+
if (Number.isFinite(error.limit)) {
|
|
1002
|
+
headers["X-RateLimit-Limit"] = String(error.limit);
|
|
1003
|
+
}
|
|
1004
|
+
if (Number.isFinite(error.count) && Number.isFinite(error.limit)) {
|
|
1005
|
+
headers["X-RateLimit-Remaining"] = String(Math.max(0, error.limit - error.count));
|
|
1006
|
+
}
|
|
1007
|
+
if (error.resetAt) {
|
|
1008
|
+
headers["X-RateLimit-Reset"] = error.resetAt;
|
|
1009
|
+
}
|
|
1010
|
+
return headers;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function stateRowsLimitFromTransactionOptions(options = {}) {
|
|
1014
|
+
const explicit = options.stateRowsLimit;
|
|
1015
|
+
if (Number.isSafeInteger(explicit) && explicit > 0) {
|
|
1016
|
+
return explicit;
|
|
1017
|
+
}
|
|
1018
|
+
const stateBytes = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
|
|
1019
|
+
return Math.max(1, Math.floor(stateBytes / 64));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function assertStateResourceLimits({ stateBytes, stateRows }, options = {}) {
|
|
1023
|
+
const stateBytesLimit = options.stateBytesLimit ?? DEFAULT_ANONYMOUS_LIMITS.stateBytes;
|
|
1024
|
+
const stateRowsLimit = stateRowsLimitFromTransactionOptions(options);
|
|
1025
|
+
if (Number.isFinite(stateRowsLimit) && stateRows > stateRowsLimit) {
|
|
1026
|
+
throw new LakebedQuotaError({
|
|
1027
|
+
bucket: "state_rows",
|
|
1028
|
+
count: stateRows,
|
|
1029
|
+
limit: stateRowsLimit,
|
|
1030
|
+
suggestion: "Delete rows or claim the deploy before retrying this mutation."
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
if (Number.isFinite(stateBytesLimit) && stateBytes > stateBytesLimit) {
|
|
1034
|
+
throw new LakebedQuotaError({
|
|
1035
|
+
bucket: "state_bytes",
|
|
1036
|
+
count: stateBytes,
|
|
1037
|
+
limit: stateBytesLimit,
|
|
1038
|
+
suggestion: "Reduce stored values, delete rows, or claim the deploy before retrying this mutation."
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function safeJsonValue(value) {
|
|
1044
|
+
if (value === undefined) {
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
try {
|
|
1049
|
+
JSON.stringify(value);
|
|
1050
|
+
return value;
|
|
1051
|
+
} catch {
|
|
1052
|
+
return String(value);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function truncateUtf8(value, maxBytes) {
|
|
1057
|
+
const text = String(value ?? "");
|
|
1058
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) {
|
|
1059
|
+
return text;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
let end = text.length;
|
|
1063
|
+
while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") > maxBytes) {
|
|
1064
|
+
end -= 1;
|
|
1065
|
+
}
|
|
1066
|
+
return text.slice(0, end);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function normalizeLogEntry(level, message, data, limits = DEFAULT_ANONYMOUS_LIMITS) {
|
|
1070
|
+
const maxEntryBytes = limits.logEntryBytes ?? DEFAULT_ANONYMOUS_LIMITS.logEntryBytes;
|
|
1071
|
+
const maxMessageBytes = Math.max(128, Math.min(maxEntryBytes, Math.floor(maxEntryBytes / 2)));
|
|
1072
|
+
const originalMessageBytes = Buffer.byteLength(String(message ?? ""), "utf8");
|
|
1073
|
+
const cleanMessage =
|
|
1074
|
+
originalMessageBytes > maxMessageBytes
|
|
1075
|
+
? `${truncateUtf8(message, Math.max(0, maxMessageBytes - 18))}...[truncated]`
|
|
1076
|
+
: String(message ?? "");
|
|
1077
|
+
let cleanData = safeJsonValue(data);
|
|
1078
|
+
let entry = { at: now(), data: cleanData, level, message: cleanMessage };
|
|
1079
|
+
|
|
1080
|
+
if (bytesOfJson(entry) > maxEntryBytes) {
|
|
1081
|
+
const dataText = JSON.stringify(cleanData ?? null);
|
|
1082
|
+
cleanData = {
|
|
1083
|
+
preview: truncateUtf8(dataText, Math.max(0, maxEntryBytes - bytesOfJson({ at: entry.at, data: null, level, message: cleanMessage }) - 128)),
|
|
1084
|
+
truncated: true,
|
|
1085
|
+
originalBytes: Buffer.byteLength(dataText, "utf8")
|
|
1086
|
+
};
|
|
1087
|
+
entry = { ...entry, data: cleanData };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (bytesOfJson(entry) > maxEntryBytes) {
|
|
1091
|
+
entry = {
|
|
1092
|
+
at: entry.at,
|
|
1093
|
+
data: { truncated: true },
|
|
1094
|
+
level,
|
|
1095
|
+
message: truncateUtf8(cleanMessage, Math.max(64, maxEntryBytes - 96))
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return entry;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
668
1102
|
function usageCounts(usage) {
|
|
669
1103
|
const windowStart = dayWindowStart();
|
|
670
1104
|
const counts = {
|
|
@@ -896,7 +1330,7 @@ function adminHtml() {
|
|
|
896
1330
|
.metrics {
|
|
897
1331
|
display: grid;
|
|
898
1332
|
gap: 8px;
|
|
899
|
-
grid-template-columns: repeat(
|
|
1333
|
+
grid-template-columns: repeat(8, minmax(120px, 1fr));
|
|
900
1334
|
margin-bottom: 14px;
|
|
901
1335
|
}
|
|
902
1336
|
|
|
@@ -1278,8 +1712,10 @@ function adminHtml() {
|
|
|
1278
1712
|
<div class="metric"><span>artifact bytes</span><strong id="metric-artifacts">0 B</strong></div>
|
|
1279
1713
|
<div class="metric"><span>state bytes</span><strong id="metric-state">0 B</strong></div>
|
|
1280
1714
|
<div class="metric"><span>state rows</span><strong id="metric-rows">0</strong></div>
|
|
1715
|
+
<div class="metric"><span>log bytes</span><strong id="metric-logs">0 B</strong></div>
|
|
1281
1716
|
<div class="metric"><span>requests today</span><strong id="metric-requests">0</strong></div>
|
|
1282
1717
|
<div class="metric"><span>mutations today</span><strong id="metric-mutations">0</strong></div>
|
|
1718
|
+
<div class="metric"><span>cleaned deploys</span><strong id="metric-cleanup">0</strong></div>
|
|
1283
1719
|
</section>
|
|
1284
1720
|
|
|
1285
1721
|
<section class="panel" id="deployments-view">
|
|
@@ -1880,8 +2316,10 @@ function adminHtml() {
|
|
|
1880
2316
|
setMetric("metric-artifacts", formatBytes(summary.totals.artifactBytes));
|
|
1881
2317
|
setMetric("metric-state", formatBytes(summary.totals.stateBytes));
|
|
1882
2318
|
setMetric("metric-rows", formatNumber(summary.totals.stateRows));
|
|
2319
|
+
setMetric("metric-logs", formatBytes(summary.totals.logBytes));
|
|
1883
2320
|
setMetric("metric-requests", formatNumber(summary.totals.requestsToday));
|
|
1884
2321
|
setMetric("metric-mutations", formatNumber(summary.totals.mutationsToday));
|
|
2322
|
+
setMetric("metric-cleanup", formatNumber(summary.cleanup?.totals?.deletedDeploys || 0));
|
|
1885
2323
|
statusLine.textContent = "Updated " + formatTime(summary.generatedAt);
|
|
1886
2324
|
}
|
|
1887
2325
|
|
|
@@ -2225,12 +2663,109 @@ function escapeHtml(value) {
|
|
|
2225
2663
|
.replace(/"/g, """);
|
|
2226
2664
|
}
|
|
2227
2665
|
|
|
2228
|
-
function
|
|
2229
|
-
return
|
|
2666
|
+
function formatHtmlNumber(value) {
|
|
2667
|
+
return new Intl.NumberFormat().format(Number(value || 0));
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function parseHtmlDate(value) {
|
|
2671
|
+
if (!value) {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2675
|
+
return Number.isFinite(date.getTime()) ? date : null;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function formatHtmlAbsoluteTime(value) {
|
|
2679
|
+
const date = parseHtmlDate(value);
|
|
2680
|
+
if (!date) {
|
|
2681
|
+
return "unknown";
|
|
2682
|
+
}
|
|
2683
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
2684
|
+
day: "numeric",
|
|
2685
|
+
hour: "numeric",
|
|
2686
|
+
minute: "2-digit",
|
|
2687
|
+
month: "short",
|
|
2688
|
+
timeZoneName: "short",
|
|
2689
|
+
year: "numeric"
|
|
2690
|
+
}).format(date);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function relativeHtmlUnit(diffMs, unitMs) {
|
|
2694
|
+
const value = Math.round(diffMs / unitMs);
|
|
2695
|
+
if (value === 0) {
|
|
2696
|
+
return diffMs < 0 ? -1 : 1;
|
|
2697
|
+
}
|
|
2698
|
+
return value;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function formatHtmlRelativeTime(value, baseDate = new Date()) {
|
|
2702
|
+
const date = parseHtmlDate(value);
|
|
2703
|
+
if (!date) {
|
|
2704
|
+
return "unknown";
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
const diffMs = date.getTime() - baseDate.getTime();
|
|
2708
|
+
const absMs = Math.abs(diffMs);
|
|
2709
|
+
const second = 1000;
|
|
2710
|
+
const minute = 60 * second;
|
|
2711
|
+
const hour = 60 * minute;
|
|
2712
|
+
const day = 24 * hour;
|
|
2713
|
+
const week = 7 * day;
|
|
2714
|
+
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
2715
|
+
|
|
2716
|
+
if (absMs < 45 * second) {
|
|
2717
|
+
return diffMs < 0 ? "just now" : "soon";
|
|
2718
|
+
}
|
|
2719
|
+
if (absMs < 45 * minute) {
|
|
2720
|
+
return formatter.format(relativeHtmlUnit(diffMs, minute), "minute");
|
|
2721
|
+
}
|
|
2722
|
+
if (absMs < 22 * hour) {
|
|
2723
|
+
return formatter.format(relativeHtmlUnit(diffMs, hour), "hour");
|
|
2724
|
+
}
|
|
2725
|
+
if (absMs < 26 * day) {
|
|
2726
|
+
return formatter.format(relativeHtmlUnit(diffMs, day), "day");
|
|
2727
|
+
}
|
|
2728
|
+
if (absMs < 8 * week) {
|
|
2729
|
+
return formatter.format(relativeHtmlUnit(diffMs, week), "week");
|
|
2730
|
+
}
|
|
2731
|
+
return formatHtmlAbsoluteTime(date);
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
function htmlTime(value) {
|
|
2735
|
+
const date = parseHtmlDate(value);
|
|
2736
|
+
if (!date) {
|
|
2737
|
+
return `<span>unknown</span>`;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
return `<time datetime="${escapeHtml(date.toISOString())}" title="${escapeHtml(formatHtmlAbsoluteTime(date))}">${escapeHtml(formatHtmlRelativeTime(date))}</time>`;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function cssClassToken(value) {
|
|
2744
|
+
return (
|
|
2745
|
+
String(value ?? "")
|
|
2746
|
+
.toLowerCase()
|
|
2747
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
2748
|
+
.replace(/^-+|-+$/g, "") || "unknown"
|
|
2749
|
+
);
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
function quotaPercent(used, limit) {
|
|
2753
|
+
const numericLimit = Number(limit || 0);
|
|
2754
|
+
if (numericLimit <= 0) {
|
|
2755
|
+
return 0;
|
|
2756
|
+
}
|
|
2757
|
+
return Math.max(0, Math.min(100, (Number(used || 0) / numericLimit) * 100));
|
|
2230
2758
|
}
|
|
2231
2759
|
|
|
2232
|
-
function
|
|
2233
|
-
|
|
2760
|
+
function quotaHtml(label, used, limit) {
|
|
2761
|
+
const percent = quotaPercent(used, limit).toFixed(2);
|
|
2762
|
+
return `<div class="quota">
|
|
2763
|
+
<div class="quota-row">
|
|
2764
|
+
<span class="quota-label">${escapeHtml(label)}</span>
|
|
2765
|
+
<span class="quota-count">${escapeHtml(formatHtmlNumber(used))} / ${escapeHtml(formatHtmlNumber(limit))}</span>
|
|
2766
|
+
</div>
|
|
2767
|
+
<div class="meter" aria-hidden="true"><span style="--usage: ${percent}%"></span></div>
|
|
2768
|
+
</div>`;
|
|
2234
2769
|
}
|
|
2235
2770
|
|
|
2236
2771
|
function developerDeploySummary({ artifact, deploy, usage }) {
|
|
@@ -2255,18 +2790,34 @@ function developerDeploySummary({ artifact, deploy, usage }) {
|
|
|
2255
2790
|
|
|
2256
2791
|
function developerHtml({ authConfigured, deploys = [], user }) {
|
|
2257
2792
|
const signedIn = Boolean(user);
|
|
2793
|
+
const activeDeploys = deploys.filter((deploy) => deploy.status === "active").length;
|
|
2794
|
+
const requestsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.requestsToday || 0), 0);
|
|
2795
|
+
const mutationsToday = deploys.reduce((total, deploy) => total + Number(deploy.usage.mutationsToday || 0), 0);
|
|
2258
2796
|
const rows = deploys
|
|
2259
|
-
.map(
|
|
2260
|
-
(deploy)
|
|
2261
|
-
|
|
2262
|
-
<
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2797
|
+
.map((deploy) => {
|
|
2798
|
+
const statusClass = cssClassToken(deploy.status);
|
|
2799
|
+
return `<article class="deploy-row">
|
|
2800
|
+
<div class="deploy-main">
|
|
2801
|
+
<div class="deploy-title-line">
|
|
2802
|
+
<a class="deploy-name" href="${escapeHtml(deploy.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(deploy.name)}</a>
|
|
2803
|
+
<span class="status status-${escapeHtml(statusClass)}">${escapeHtml(deploy.status)}</span>
|
|
2804
|
+
${deploy.expiresAt ? `<span class="expiry">expires ${htmlTime(deploy.expiresAt)}</span>` : ""}
|
|
2805
|
+
</div>
|
|
2806
|
+
<div class="deploy-ids">
|
|
2807
|
+
<span>${escapeHtml(deploy.deployId)}</span>
|
|
2808
|
+
<span>${escapeHtml(deploy.slug)}</span>
|
|
2809
|
+
</div>
|
|
2810
|
+
</div>
|
|
2811
|
+
<div class="activity">
|
|
2812
|
+
<div class="activity-item"><span>Updated</span>${htmlTime(deploy.updatedAt)}</div>
|
|
2813
|
+
<div class="activity-item"><span>Created</span>${htmlTime(deploy.createdAt)}</div>
|
|
2814
|
+
</div>
|
|
2815
|
+
<div class="usage-grid">
|
|
2816
|
+
${quotaHtml("Requests", deploy.usage.requestsToday, deploy.limits.requestsPerDay)}
|
|
2817
|
+
${quotaHtml("Mutations", deploy.usage.mutationsToday, deploy.limits.mutationsPerDay)}
|
|
2818
|
+
</div>
|
|
2819
|
+
</article>`;
|
|
2820
|
+
})
|
|
2270
2821
|
.join("");
|
|
2271
2822
|
|
|
2272
2823
|
return `<!doctype html>
|
|
@@ -2278,14 +2829,18 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2278
2829
|
<style>
|
|
2279
2830
|
:root {
|
|
2280
2831
|
color-scheme: dark;
|
|
2281
|
-
--
|
|
2282
|
-
--
|
|
2283
|
-
--
|
|
2284
|
-
--
|
|
2285
|
-
--
|
|
2286
|
-
--
|
|
2287
|
-
--
|
|
2288
|
-
--
|
|
2832
|
+
--accent: #b7f26d;
|
|
2833
|
+
--bg: #070a09;
|
|
2834
|
+
--cyan: #71d6ff;
|
|
2835
|
+
--danger: #ff7b72;
|
|
2836
|
+
--line: #26302b;
|
|
2837
|
+
--line-strong: #3b4740;
|
|
2838
|
+
--muted: #8f9c94;
|
|
2839
|
+
--panel: #0f1513;
|
|
2840
|
+
--panel-raised: #141b18;
|
|
2841
|
+
--soft: #bfcbc3;
|
|
2842
|
+
--text: #eef6f0;
|
|
2843
|
+
--warning: #ffd166;
|
|
2289
2844
|
}
|
|
2290
2845
|
|
|
2291
2846
|
* {
|
|
@@ -2295,13 +2850,12 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2295
2850
|
body {
|
|
2296
2851
|
background: var(--bg);
|
|
2297
2852
|
color: var(--text);
|
|
2298
|
-
font-family:
|
|
2299
|
-
letter-spacing: 0;
|
|
2853
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2300
2854
|
margin: 0;
|
|
2301
2855
|
}
|
|
2302
2856
|
|
|
2303
2857
|
a {
|
|
2304
|
-
color:
|
|
2858
|
+
color: inherit;
|
|
2305
2859
|
text-decoration: none;
|
|
2306
2860
|
}
|
|
2307
2861
|
|
|
@@ -2311,50 +2865,104 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2311
2865
|
|
|
2312
2866
|
.shell {
|
|
2313
2867
|
margin: 0 auto;
|
|
2314
|
-
max-width:
|
|
2868
|
+
max-width: 1560px;
|
|
2315
2869
|
min-height: 100vh;
|
|
2316
|
-
padding:
|
|
2870
|
+
padding: 34px 32px 56px;
|
|
2871
|
+
width: 100%;
|
|
2317
2872
|
}
|
|
2318
2873
|
|
|
2319
2874
|
header {
|
|
2320
2875
|
align-items: center;
|
|
2876
|
+
border-bottom: 1px solid var(--line);
|
|
2321
2877
|
display: flex;
|
|
2322
2878
|
gap: 16px;
|
|
2323
2879
|
justify-content: space-between;
|
|
2324
|
-
margin-bottom:
|
|
2880
|
+
margin-bottom: 22px;
|
|
2881
|
+
padding-bottom: 22px;
|
|
2325
2882
|
}
|
|
2326
2883
|
|
|
2327
2884
|
h1 {
|
|
2328
|
-
font-size:
|
|
2329
|
-
line-height: 1;
|
|
2885
|
+
font-size: 40px;
|
|
2886
|
+
line-height: 1.05;
|
|
2330
2887
|
margin: 0;
|
|
2331
2888
|
}
|
|
2332
2889
|
|
|
2333
2890
|
.eyebrow,
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
.
|
|
2891
|
+
.meta,
|
|
2892
|
+
.deploy-ids,
|
|
2893
|
+
.activity-item span,
|
|
2894
|
+
.expiry,
|
|
2895
|
+
.list-head,
|
|
2896
|
+
.quota-label,
|
|
2897
|
+
.quota-count,
|
|
2898
|
+
.status {
|
|
2337
2899
|
color: var(--muted);
|
|
2338
2900
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
2339
2901
|
font-size: 12px;
|
|
2340
2902
|
}
|
|
2341
2903
|
|
|
2904
|
+
.eyebrow {
|
|
2905
|
+
color: var(--accent);
|
|
2906
|
+
margin-bottom: 6px;
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
.meta {
|
|
2910
|
+
margin-top: 4px;
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2342
2913
|
.button {
|
|
2343
|
-
|
|
2344
|
-
|
|
2914
|
+
align-items: center;
|
|
2915
|
+
background: transparent;
|
|
2916
|
+
border: 1px solid var(--line-strong);
|
|
2345
2917
|
border-radius: 6px;
|
|
2346
|
-
color: var(--
|
|
2918
|
+
color: var(--text);
|
|
2347
2919
|
display: inline-flex;
|
|
2348
2920
|
font-weight: 700;
|
|
2349
2921
|
min-height: 40px;
|
|
2350
2922
|
padding: 10px 14px;
|
|
2351
2923
|
}
|
|
2352
2924
|
|
|
2925
|
+
.button:hover {
|
|
2926
|
+
border-color: var(--accent);
|
|
2927
|
+
text-decoration: none;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2353
2930
|
.button.secondary {
|
|
2354
2931
|
background: transparent;
|
|
2355
2932
|
color: var(--text);
|
|
2356
2933
|
}
|
|
2357
2934
|
|
|
2935
|
+
.summary {
|
|
2936
|
+
background: var(--line);
|
|
2937
|
+
border: 1px solid var(--line);
|
|
2938
|
+
border-radius: 8px;
|
|
2939
|
+
display: grid;
|
|
2940
|
+
gap: 1px;
|
|
2941
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
2942
|
+
margin-bottom: 18px;
|
|
2943
|
+
overflow: hidden;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
.metric {
|
|
2947
|
+
background: var(--panel);
|
|
2948
|
+
padding: 15px 16px;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
.metric span {
|
|
2952
|
+
color: var(--muted);
|
|
2953
|
+
display: block;
|
|
2954
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
2955
|
+
font-size: 12px;
|
|
2956
|
+
margin-bottom: 6px;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
.metric strong {
|
|
2960
|
+
color: var(--text);
|
|
2961
|
+
display: block;
|
|
2962
|
+
font-size: 22px;
|
|
2963
|
+
line-height: 1;
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2358
2966
|
.panel {
|
|
2359
2967
|
background: var(--panel);
|
|
2360
2968
|
border: 1px solid var(--line);
|
|
@@ -2367,51 +2975,160 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2367
2975
|
padding: 22px;
|
|
2368
2976
|
}
|
|
2369
2977
|
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
width: 100%;
|
|
2978
|
+
.deploy-list {
|
|
2979
|
+
display: grid;
|
|
2373
2980
|
}
|
|
2374
2981
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
}
|
|
2982
|
+
.list-head,
|
|
2983
|
+
.deploy-row {
|
|
2984
|
+
align-items: center;
|
|
2985
|
+
display: grid;
|
|
2986
|
+
gap: 24px;
|
|
2987
|
+
grid-template-columns: minmax(360px, 1fr) minmax(210px, 300px) minmax(360px, 430px);
|
|
2988
|
+
}
|
|
2382
2989
|
|
|
2383
|
-
|
|
2990
|
+
.list-head {
|
|
2991
|
+
background: #0b100e;
|
|
2992
|
+
border-bottom: 1px solid var(--line);
|
|
2993
|
+
padding: 12px 18px;
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
.deploy-row {
|
|
2997
|
+
background: var(--panel);
|
|
2998
|
+
border-bottom: 1px solid var(--line);
|
|
2999
|
+
min-width: 0;
|
|
3000
|
+
padding: 18px;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
.deploy-row:last-child {
|
|
2384
3004
|
border-bottom: 0;
|
|
2385
3005
|
}
|
|
2386
3006
|
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
gap: 4px;
|
|
3007
|
+
.deploy-row:hover {
|
|
3008
|
+
background: var(--panel-raised);
|
|
2390
3009
|
}
|
|
2391
3010
|
|
|
2392
|
-
.
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
font-size: 12px;
|
|
2398
|
-
padding: 3px 8px;
|
|
3011
|
+
.deploy-main,
|
|
3012
|
+
.activity,
|
|
3013
|
+
.usage-grid,
|
|
3014
|
+
.quota {
|
|
3015
|
+
min-width: 0;
|
|
2399
3016
|
}
|
|
2400
3017
|
|
|
2401
|
-
.
|
|
2402
|
-
|
|
3018
|
+
.deploy-title-line {
|
|
3019
|
+
align-items: center;
|
|
3020
|
+
display: flex;
|
|
3021
|
+
flex-wrap: wrap;
|
|
3022
|
+
gap: 8px 10px;
|
|
3023
|
+
margin-bottom: 8px;
|
|
3024
|
+
min-width: 0;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
.deploy-name {
|
|
3028
|
+
color: var(--text);
|
|
3029
|
+
font-size: 16px;
|
|
3030
|
+
font-weight: 700;
|
|
3031
|
+
overflow-wrap: anywhere;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
.deploy-ids {
|
|
3035
|
+
align-items: center;
|
|
3036
|
+
display: flex;
|
|
3037
|
+
flex-wrap: wrap;
|
|
3038
|
+
gap: 6px 12px;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
.deploy-ids span {
|
|
3042
|
+
overflow-wrap: anywhere;
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
.status {
|
|
3046
|
+
align-items: center;
|
|
2403
3047
|
color: var(--accent);
|
|
3048
|
+
display: inline-flex;
|
|
3049
|
+
gap: 6px;
|
|
3050
|
+
white-space: nowrap;
|
|
2404
3051
|
}
|
|
2405
3052
|
|
|
2406
|
-
.
|
|
2407
|
-
|
|
2408
|
-
border-
|
|
2409
|
-
|
|
3053
|
+
.status::before {
|
|
3054
|
+
background: var(--accent);
|
|
3055
|
+
border-radius: 999px;
|
|
3056
|
+
box-shadow: 0 0 0 3px rgba(183, 242, 109, 0.14);
|
|
3057
|
+
content: "";
|
|
3058
|
+
height: 7px;
|
|
3059
|
+
width: 7px;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
.status-expired,
|
|
3063
|
+
.status-terminated {
|
|
3064
|
+
color: var(--danger);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
.status-expired::before,
|
|
3068
|
+
.status-terminated::before {
|
|
3069
|
+
background: var(--danger);
|
|
3070
|
+
box-shadow: 0 0 0 3px rgba(255, 123, 114, 0.15);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
.expiry {
|
|
3074
|
+
color: var(--warning);
|
|
3075
|
+
white-space: nowrap;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
.activity {
|
|
3079
|
+
display: grid;
|
|
3080
|
+
gap: 6px;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
.activity-item {
|
|
3084
|
+
align-items: baseline;
|
|
3085
|
+
color: var(--soft);
|
|
3086
|
+
display: flex;
|
|
3087
|
+
gap: 12px;
|
|
3088
|
+
justify-content: space-between;
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
time {
|
|
3092
|
+
color: var(--text);
|
|
3093
|
+
white-space: nowrap;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
.usage-grid {
|
|
3097
|
+
display: grid;
|
|
3098
|
+
gap: 12px;
|
|
3099
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
.quota-row {
|
|
3103
|
+
align-items: baseline;
|
|
3104
|
+
display: flex;
|
|
3105
|
+
gap: 12px;
|
|
3106
|
+
justify-content: space-between;
|
|
3107
|
+
margin-bottom: 8px;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
.quota-count {
|
|
3111
|
+
color: var(--text);
|
|
3112
|
+
white-space: nowrap;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
.meter {
|
|
3116
|
+
background: #222b26;
|
|
3117
|
+
border-radius: 999px;
|
|
3118
|
+
height: 6px;
|
|
3119
|
+
overflow: hidden;
|
|
2410
3120
|
}
|
|
2411
3121
|
|
|
2412
|
-
|
|
3122
|
+
.meter span {
|
|
3123
|
+
background: linear-gradient(90deg, var(--accent), var(--cyan));
|
|
3124
|
+
display: block;
|
|
3125
|
+
height: 100%;
|
|
3126
|
+
width: var(--usage);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
@media (max-width: 980px) {
|
|
2413
3130
|
.shell {
|
|
2414
|
-
padding: 18px;
|
|
3131
|
+
padding: 24px 18px 40px;
|
|
2415
3132
|
}
|
|
2416
3133
|
|
|
2417
3134
|
header {
|
|
@@ -2419,12 +3136,46 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2419
3136
|
flex-direction: column;
|
|
2420
3137
|
}
|
|
2421
3138
|
|
|
2422
|
-
|
|
2423
|
-
|
|
3139
|
+
h1 {
|
|
3140
|
+
font-size: 32px;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
.summary {
|
|
3144
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
.list-head {
|
|
3148
|
+
display: none;
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
.deploy-row {
|
|
3152
|
+
align-items: stretch;
|
|
3153
|
+
gap: 16px;
|
|
3154
|
+
grid-template-columns: 1fr;
|
|
2424
3155
|
}
|
|
2425
3156
|
|
|
2426
|
-
.
|
|
2427
|
-
|
|
3157
|
+
.activity-item {
|
|
3158
|
+
justify-content: flex-start;
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
@media (max-width: 640px) {
|
|
3163
|
+
.shell {
|
|
3164
|
+
padding: 20px 12px 36px;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
.summary,
|
|
3168
|
+
.usage-grid {
|
|
3169
|
+
grid-template-columns: 1fr;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
.deploy-ids {
|
|
3173
|
+
display: grid;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
.activity-item {
|
|
3177
|
+
display: grid;
|
|
3178
|
+
gap: 2px;
|
|
2428
3179
|
}
|
|
2429
3180
|
}
|
|
2430
3181
|
</style>
|
|
@@ -2447,6 +3198,15 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2447
3198
|
}
|
|
2448
3199
|
</div>
|
|
2449
3200
|
</header>
|
|
3201
|
+
${
|
|
3202
|
+
signedIn && deploys.length
|
|
3203
|
+
? `<section class="summary" aria-label="Deployment summary">
|
|
3204
|
+
<div class="metric"><span>active deploys</span><strong>${escapeHtml(formatHtmlNumber(activeDeploys))}</strong></div>
|
|
3205
|
+
<div class="metric"><span>requests today</span><strong>${escapeHtml(formatHtmlNumber(requestsToday))}</strong></div>
|
|
3206
|
+
<div class="metric"><span>mutations today</span><strong>${escapeHtml(formatHtmlNumber(mutationsToday))}</strong></div>
|
|
3207
|
+
</section>`
|
|
3208
|
+
: ""
|
|
3209
|
+
}
|
|
2450
3210
|
|
|
2451
3211
|
${
|
|
2452
3212
|
!authConfigured
|
|
@@ -2456,20 +3216,14 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2456
3216
|
: deploys.length === 0
|
|
2457
3217
|
? `<section class="panel"><div class="empty">No claimed deployments.</div></section>`
|
|
2458
3218
|
: `<section class="panel">
|
|
2459
|
-
<
|
|
2460
|
-
<
|
|
2461
|
-
<
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
<th>Requests</th>
|
|
2468
|
-
<th>Mutations</th>
|
|
2469
|
-
</tr>
|
|
2470
|
-
</thead>
|
|
2471
|
-
<tbody>${rows}</tbody>
|
|
2472
|
-
</table>
|
|
3219
|
+
<div class="deploy-list">
|
|
3220
|
+
<div class="list-head" aria-hidden="true">
|
|
3221
|
+
<span>Deploy</span>
|
|
3222
|
+
<span>Activity</span>
|
|
3223
|
+
<span>Usage today</span>
|
|
3224
|
+
</div>
|
|
3225
|
+
${rows}
|
|
3226
|
+
</div>
|
|
2473
3227
|
</section>`
|
|
2474
3228
|
}
|
|
2475
3229
|
</main>
|
|
@@ -2480,14 +3234,19 @@ function developerHtml({ authConfigured, deploys = [], user }) {
|
|
|
2480
3234
|
export class MemoryAnonymousStore {
|
|
2481
3235
|
constructor() {
|
|
2482
3236
|
this.artifacts = new Map();
|
|
3237
|
+
this.deployDomainsByHostname = new Map();
|
|
2483
3238
|
this.deploys = new Map();
|
|
2484
3239
|
this.deploysBySlug = new Map();
|
|
3240
|
+
this.deployCreateQuotaEvents = new Map();
|
|
3241
|
+
this.clientQuotaEvents = new Map();
|
|
2485
3242
|
this.logs = new Map();
|
|
2486
3243
|
this.quotaEvents = new Map();
|
|
2487
3244
|
this.queues = new Map();
|
|
2488
3245
|
this.rows = new Map();
|
|
2489
3246
|
this.serverEnv = new Map();
|
|
2490
3247
|
this.users = new Map();
|
|
3248
|
+
this.cleanupRuns = [];
|
|
3249
|
+
this.cleanupTotals = {};
|
|
2491
3250
|
}
|
|
2492
3251
|
|
|
2493
3252
|
async initialize() {}
|
|
@@ -2614,11 +3373,30 @@ export class MemoryAnonymousStore {
|
|
|
2614
3373
|
});
|
|
2615
3374
|
}
|
|
2616
3375
|
|
|
3376
|
+
decrementArtifactRef(artifactHash) {
|
|
3377
|
+
const artifact = this.artifacts.get(artifactHash);
|
|
3378
|
+
if (!artifact) {
|
|
3379
|
+
return false;
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
const refCount = Math.max(0, Number(artifact.refCount ?? 0) - 1);
|
|
3383
|
+
if (refCount <= 0) {
|
|
3384
|
+
this.artifacts.delete(artifactHash);
|
|
3385
|
+
return true;
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
this.artifacts.set(artifactHash, { ...artifact, refCount });
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
2617
3392
|
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
2618
3393
|
const deployId = createDeployId();
|
|
2619
3394
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
2620
3395
|
let slug = createSlug();
|
|
2621
|
-
while (
|
|
3396
|
+
while (
|
|
3397
|
+
this.deploysBySlug.has(slug) ||
|
|
3398
|
+
(normalizedAppBaseDomain && this.deployDomainsByHostname.has(`${slug}.${normalizedAppBaseDomain}`))
|
|
3399
|
+
) {
|
|
2622
3400
|
slug = createSlug();
|
|
2623
3401
|
}
|
|
2624
3402
|
|
|
@@ -2699,6 +3477,7 @@ export class MemoryAnonymousStore {
|
|
|
2699
3477
|
createdAt: updatedAt
|
|
2700
3478
|
});
|
|
2701
3479
|
this.deploys.set(deployId, deploy);
|
|
3480
|
+
this.decrementArtifactRef(currentDeploy.artifactHash);
|
|
2702
3481
|
if (serverEnv !== undefined) {
|
|
2703
3482
|
this.serverEnv.set(deployId, { ...serverEnv });
|
|
2704
3483
|
}
|
|
@@ -2757,6 +3536,62 @@ export class MemoryAnonymousStore {
|
|
|
2757
3536
|
return id ? this.getDeployById(id) : null;
|
|
2758
3537
|
}
|
|
2759
3538
|
|
|
3539
|
+
async getDeployDomainByHostname(hostname) {
|
|
3540
|
+
const domain = this.deployDomainsByHostname.get(hostname);
|
|
3541
|
+
return domain ? { ...domain } : null;
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
async listDeployDomainsForDeploy(deployId) {
|
|
3545
|
+
return Array.from(this.deployDomainsByHostname.values())
|
|
3546
|
+
.filter((domain) => domain.deployId === deployId)
|
|
3547
|
+
.sort((left, right) => String(left.hostname).localeCompare(String(right.hostname)))
|
|
3548
|
+
.map((domain) => ({ ...domain }));
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
|
|
3552
|
+
const deploy = await this.getStoredDeployById(deployId);
|
|
3553
|
+
if (!deploy) {
|
|
3554
|
+
return { status: "missing" };
|
|
3555
|
+
}
|
|
3556
|
+
if (!deploy.ownerId) {
|
|
3557
|
+
return { deploy, status: "unclaimed" };
|
|
3558
|
+
}
|
|
3559
|
+
if (deploy.ownerId !== ownerId) {
|
|
3560
|
+
return { deploy, status: "forbidden" };
|
|
3561
|
+
}
|
|
3562
|
+
if (deploy.status !== "active" || isExpired(deploy)) {
|
|
3563
|
+
return { deploy, status: "inactive" };
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
const existingDomain = this.deployDomainsByHostname.get(hostname);
|
|
3567
|
+
if (existingDomain) {
|
|
3568
|
+
if (existingDomain.deployId === deployId) {
|
|
3569
|
+
return { deploy, domain: { ...existingDomain }, status: "exists" };
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
return { deploy, domain: { ...existingDomain }, status: "conflict" };
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
if (this.deploysBySlug.has(label)) {
|
|
3576
|
+
return { deploy, status: "slug_conflict" };
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
const timestamp = now();
|
|
3580
|
+
const domain = {
|
|
3581
|
+
createdAt: timestamp,
|
|
3582
|
+
deployId,
|
|
3583
|
+
hostname,
|
|
3584
|
+
isPrimary: false,
|
|
3585
|
+
kind: "lakebed_subdomain",
|
|
3586
|
+
ownerId,
|
|
3587
|
+
status: "active",
|
|
3588
|
+
updatedAt: timestamp,
|
|
3589
|
+
url: `https://${hostname}`
|
|
3590
|
+
};
|
|
3591
|
+
this.deployDomainsByHostname.set(hostname, domain);
|
|
3592
|
+
return { deploy, domain: { ...domain }, status: "created" };
|
|
3593
|
+
}
|
|
3594
|
+
|
|
2760
3595
|
async getArtifact(hash) {
|
|
2761
3596
|
return this.artifacts.get(hash) ?? null;
|
|
2762
3597
|
}
|
|
@@ -2806,8 +3641,45 @@ export class MemoryAnonymousStore {
|
|
|
2806
3641
|
return { ...(this.serverEnv.get(deployId) ?? {}) };
|
|
2807
3642
|
}
|
|
2808
3643
|
|
|
2809
|
-
|
|
2810
|
-
const
|
|
3644
|
+
snapshotRowsForDeploy(deployId) {
|
|
3645
|
+
const snapshot = new Map();
|
|
3646
|
+
for (const [key, rows] of this.rows) {
|
|
3647
|
+
const [eventDeployId] = key.split(":");
|
|
3648
|
+
if (eventDeployId === deployId) {
|
|
3649
|
+
snapshot.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
return snapshot;
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
restoreRowsForDeploy(deployId, snapshot) {
|
|
3656
|
+
for (const key of Array.from(this.rows.keys())) {
|
|
3657
|
+
const [eventDeployId] = key.split(":");
|
|
3658
|
+
if (eventDeployId === deployId) {
|
|
3659
|
+
this.rows.delete(key);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
for (const [key, rows] of snapshot) {
|
|
3663
|
+
this.rows.set(key, new Map(Array.from(rows.entries()).map(([rowId, row]) => [rowId, { ...row }])));
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
assertStateWithinLimits(deployId, options) {
|
|
3668
|
+
assertStateResourceLimits(this.stateResourceForDeploy(deployId), options);
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
async transaction(deployId, handler, options = {}) {
|
|
3672
|
+
const run = async () => {
|
|
3673
|
+
const snapshot = this.snapshotRowsForDeploy(deployId);
|
|
3674
|
+
try {
|
|
3675
|
+
const result = await handler(this);
|
|
3676
|
+
this.assertStateWithinLimits(deployId, options);
|
|
3677
|
+
return result;
|
|
3678
|
+
} catch (error) {
|
|
3679
|
+
this.restoreRowsForDeploy(deployId, snapshot);
|
|
3680
|
+
throw error;
|
|
3681
|
+
}
|
|
3682
|
+
};
|
|
2811
3683
|
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
2812
3684
|
this.queues.set(
|
|
2813
3685
|
deployId,
|
|
@@ -2820,9 +3692,13 @@ export class MemoryAnonymousStore {
|
|
|
2820
3692
|
}
|
|
2821
3693
|
|
|
2822
3694
|
async appendLog(deployId, level, message, data) {
|
|
2823
|
-
const entries = this.logs.get(deployId) ?? [];
|
|
2824
|
-
|
|
2825
|
-
|
|
3695
|
+
const entries = [...(this.logs.get(deployId) ?? []), normalizeLogEntry(level, message, data)];
|
|
3696
|
+
const maxEntries = DEFAULT_ANONYMOUS_LIMITS.logEntries;
|
|
3697
|
+
const maxBytes = DEFAULT_ANONYMOUS_LIMITS.logBytes;
|
|
3698
|
+
while (entries.length > maxEntries || bytesOfJson(entries) > maxBytes) {
|
|
3699
|
+
entries.shift();
|
|
3700
|
+
}
|
|
3701
|
+
this.logs.set(deployId, entries);
|
|
2826
3702
|
}
|
|
2827
3703
|
|
|
2828
3704
|
async readLogs(deployId, limit = 100) {
|
|
@@ -2858,12 +3734,67 @@ export class MemoryAnonymousStore {
|
|
|
2858
3734
|
}
|
|
2859
3735
|
|
|
2860
3736
|
async incrementQuota(deployId, bucket, limit) {
|
|
3737
|
+
if (!Number.isFinite(limit)) {
|
|
3738
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3739
|
+
}
|
|
3740
|
+
|
|
2861
3741
|
const windowStart = dayWindowStart();
|
|
2862
3742
|
const key = `${deployId}:${bucket}:${windowStart}`;
|
|
2863
3743
|
const count = (this.quotaEvents.get(key) ?? 0) + 1;
|
|
2864
3744
|
this.quotaEvents.set(key, count);
|
|
2865
3745
|
if (count > limit) {
|
|
2866
|
-
throw new
|
|
3746
|
+
throw new LakebedQuotaError({
|
|
3747
|
+
bucket,
|
|
3748
|
+
count,
|
|
3749
|
+
limit,
|
|
3750
|
+
resetAt: quotaResetAt(windowStart),
|
|
3751
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3752
|
+
suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
|
|
3753
|
+
});
|
|
3754
|
+
}
|
|
3755
|
+
return { bucket, count, limit, windowStart };
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
async incrementDeployCreateQuota(clientKey, bucket, limit) {
|
|
3759
|
+
if (!Number.isFinite(limit)) {
|
|
3760
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3761
|
+
}
|
|
3762
|
+
|
|
3763
|
+
const windowStart = dayWindowStart();
|
|
3764
|
+
const key = JSON.stringify([clientKey, bucket, windowStart]);
|
|
3765
|
+
const count = (this.deployCreateQuotaEvents.get(key) ?? 0) + 1;
|
|
3766
|
+
this.deployCreateQuotaEvents.set(key, count);
|
|
3767
|
+
if (count > limit) {
|
|
3768
|
+
throw new LakebedQuotaError({
|
|
3769
|
+
bucket,
|
|
3770
|
+
count,
|
|
3771
|
+
limit,
|
|
3772
|
+
resetAt: quotaResetAt(windowStart),
|
|
3773
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3774
|
+
suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
|
|
3775
|
+
});
|
|
3776
|
+
}
|
|
3777
|
+
return { bucket, count, limit, windowStart };
|
|
3778
|
+
}
|
|
3779
|
+
|
|
3780
|
+
async incrementClientQuota(clientKey, bucket, limit) {
|
|
3781
|
+
if (!Number.isFinite(limit)) {
|
|
3782
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
const windowStart = dayWindowStart();
|
|
3786
|
+
const key = JSON.stringify([clientKey, bucket, windowStart]);
|
|
3787
|
+
const count = (this.clientQuotaEvents.get(key) ?? 0) + 1;
|
|
3788
|
+
this.clientQuotaEvents.set(key, count);
|
|
3789
|
+
if (count > limit) {
|
|
3790
|
+
throw new LakebedQuotaError({
|
|
3791
|
+
bucket,
|
|
3792
|
+
count,
|
|
3793
|
+
limit,
|
|
3794
|
+
resetAt: quotaResetAt(windowStart),
|
|
3795
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
3796
|
+
suggestion: clientTrafficQuotaSuggestion(bucket)
|
|
3797
|
+
});
|
|
2867
3798
|
}
|
|
2868
3799
|
return { bucket, count, limit, windowStart };
|
|
2869
3800
|
}
|
|
@@ -2949,6 +3880,160 @@ export class MemoryAnonymousStore {
|
|
|
2949
3880
|
|
|
2950
3881
|
return summaries;
|
|
2951
3882
|
}
|
|
3883
|
+
|
|
3884
|
+
deleteDeployResources(deployId) {
|
|
3885
|
+
const deploy = this.deploys.get(deployId);
|
|
3886
|
+
if (!deploy) {
|
|
3887
|
+
return {
|
|
3888
|
+
deletedArtifacts: 0,
|
|
3889
|
+
deletedDomains: 0,
|
|
3890
|
+
deletedLogEntries: 0,
|
|
3891
|
+
deletedQuotaEvents: 0,
|
|
3892
|
+
deletedServerEnv: 0,
|
|
3893
|
+
deletedStateRows: 0
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
let deletedStateRows = 0;
|
|
3898
|
+
for (const [key, rows] of Array.from(this.rows.entries())) {
|
|
3899
|
+
const [eventDeployId] = key.split(":");
|
|
3900
|
+
if (eventDeployId === deployId) {
|
|
3901
|
+
deletedStateRows += rows.size;
|
|
3902
|
+
this.rows.delete(key);
|
|
3903
|
+
}
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
const deletedLogEntries = this.logs.get(deployId)?.length ?? 0;
|
|
3907
|
+
this.logs.delete(deployId);
|
|
3908
|
+
|
|
3909
|
+
let deletedQuotaEvents = 0;
|
|
3910
|
+
for (const key of Array.from(this.quotaEvents.keys())) {
|
|
3911
|
+
const [eventDeployId] = key.split(":");
|
|
3912
|
+
if (eventDeployId === deployId) {
|
|
3913
|
+
deletedQuotaEvents += 1;
|
|
3914
|
+
this.quotaEvents.delete(key);
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
const deletedServerEnv = this.serverEnv.has(deployId) ? Object.keys(this.serverEnv.get(deployId) ?? {}).length : 0;
|
|
3919
|
+
this.serverEnv.delete(deployId);
|
|
3920
|
+
|
|
3921
|
+
let deletedDomains = 0;
|
|
3922
|
+
for (const [hostname, domain] of Array.from(this.deployDomainsByHostname.entries())) {
|
|
3923
|
+
if (domain.deployId === deployId) {
|
|
3924
|
+
this.deployDomainsByHostname.delete(hostname);
|
|
3925
|
+
deletedDomains += 1;
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
this.deploys.delete(deployId);
|
|
3930
|
+
this.deploysBySlug.delete(deploy.slug);
|
|
3931
|
+
const deletedArtifacts = this.decrementArtifactRef(deploy.artifactHash) ? 1 : 0;
|
|
3932
|
+
|
|
3933
|
+
return {
|
|
3934
|
+
deletedArtifacts,
|
|
3935
|
+
deletedDomains,
|
|
3936
|
+
deletedLogEntries,
|
|
3937
|
+
deletedQuotaEvents,
|
|
3938
|
+
deletedServerEnv,
|
|
3939
|
+
deletedStateRows
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
recordCleanupRun(stats) {
|
|
3944
|
+
this.cleanupRuns.push(stats);
|
|
3945
|
+
this.cleanupRuns = this.cleanupRuns.slice(-20);
|
|
3946
|
+
for (const [key, value] of Object.entries(stats)) {
|
|
3947
|
+
if (typeof value === "number") {
|
|
3948
|
+
this.cleanupTotals[key] = (this.cleanupTotals[key] ?? 0) + value;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
|
|
3954
|
+
const startedAt = new Date(nowMs).toISOString();
|
|
3955
|
+
const markCutoffMs = nowMs - graceSeconds * 1000;
|
|
3956
|
+
const deleteCutoffMs = nowMs - retentionSeconds * 1000;
|
|
3957
|
+
const stats = {
|
|
3958
|
+
deletedArtifacts: 0,
|
|
3959
|
+
deletedDomains: 0,
|
|
3960
|
+
deletedDeploys: 0,
|
|
3961
|
+
deletedLogEntries: 0,
|
|
3962
|
+
deletedQuotaEvents: 0,
|
|
3963
|
+
deletedServerEnv: 0,
|
|
3964
|
+
deletedStateRows: 0,
|
|
3965
|
+
examinedDeploys: this.deploys.size,
|
|
3966
|
+
finishedAt: null,
|
|
3967
|
+
markedTerminated: 0,
|
|
3968
|
+
startedAt
|
|
3969
|
+
};
|
|
3970
|
+
|
|
3971
|
+
for (const deploy of Array.from(this.deploys.values())) {
|
|
3972
|
+
if (deploy.ownerId || !deploy.expiresAt) {
|
|
3973
|
+
continue;
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
const expiresAtMs = Date.parse(deploy.expiresAt);
|
|
3977
|
+
if (Number.isFinite(expiresAtMs) && expiresAtMs <= markCutoffMs && deploy.status === "active") {
|
|
3978
|
+
this.deploys.set(deploy.id, { ...deploy, status: "terminated", updatedAt: new Date(nowMs).toISOString() });
|
|
3979
|
+
stats.markedTerminated += 1;
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
for (const deploy of Array.from(this.deploys.values())) {
|
|
3984
|
+
if (deploy.ownerId || !deploy.expiresAt) {
|
|
3985
|
+
continue;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
const expiresAtMs = Date.parse(deploy.expiresAt);
|
|
3989
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs > deleteCutoffMs) {
|
|
3990
|
+
continue;
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
const deleted = this.deleteDeployResources(deploy.id);
|
|
3994
|
+
stats.deletedDeploys += 1;
|
|
3995
|
+
for (const [key, value] of Object.entries(deleted)) {
|
|
3996
|
+
stats[key] += value;
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
for (const [key] of Array.from(this.deployCreateQuotaEvents.entries())) {
|
|
4001
|
+
try {
|
|
4002
|
+
const [, , windowStart] = JSON.parse(key);
|
|
4003
|
+
if (Date.parse(windowStart) <= deleteCutoffMs) {
|
|
4004
|
+
this.deployCreateQuotaEvents.delete(key);
|
|
4005
|
+
stats.deletedQuotaEvents += 1;
|
|
4006
|
+
}
|
|
4007
|
+
} catch {
|
|
4008
|
+
this.deployCreateQuotaEvents.delete(key);
|
|
4009
|
+
stats.deletedQuotaEvents += 1;
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
for (const [key] of Array.from(this.clientQuotaEvents.entries())) {
|
|
4013
|
+
try {
|
|
4014
|
+
const [, , windowStart] = JSON.parse(key);
|
|
4015
|
+
if (Date.parse(windowStart) <= deleteCutoffMs) {
|
|
4016
|
+
this.clientQuotaEvents.delete(key);
|
|
4017
|
+
stats.deletedQuotaEvents += 1;
|
|
4018
|
+
}
|
|
4019
|
+
} catch {
|
|
4020
|
+
this.clientQuotaEvents.delete(key);
|
|
4021
|
+
stats.deletedQuotaEvents += 1;
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
stats.finishedAt = now();
|
|
4026
|
+
this.recordCleanupRun(stats);
|
|
4027
|
+
return stats;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
async readCleanupActivity() {
|
|
4031
|
+
return {
|
|
4032
|
+
lastRun: this.cleanupRuns[this.cleanupRuns.length - 1] ?? null,
|
|
4033
|
+
recentRuns: [...this.cleanupRuns].reverse(),
|
|
4034
|
+
totals: { ...this.cleanupTotals }
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
2952
4037
|
}
|
|
2953
4038
|
|
|
2954
4039
|
export class PostgresAnonymousStore {
|
|
@@ -2991,6 +4076,20 @@ export class PostgresAnonymousStore {
|
|
|
2991
4076
|
await this.query("alter table deploys alter column updated_at set not null");
|
|
2992
4077
|
await this.query("alter table deploys alter column expires_at drop not null");
|
|
2993
4078
|
await this.query("update deploys set expires_at = null where owner_id is not null and expires_at is not null");
|
|
4079
|
+
await this.query(`
|
|
4080
|
+
create table if not exists deploy_domains(
|
|
4081
|
+
hostname text primary key,
|
|
4082
|
+
deploy_id text not null references deploys(id) on delete cascade,
|
|
4083
|
+
owner_id text not null,
|
|
4084
|
+
kind text not null,
|
|
4085
|
+
status text not null,
|
|
4086
|
+
is_primary boolean not null default false,
|
|
4087
|
+
created_at timestamptz not null,
|
|
4088
|
+
updated_at timestamptz not null
|
|
4089
|
+
)
|
|
4090
|
+
`);
|
|
4091
|
+
await this.query("create index if not exists deploy_domains_deploy_id_idx on deploy_domains(deploy_id)");
|
|
4092
|
+
await this.query("create index if not exists deploy_domains_owner_id_idx on deploy_domains(owner_id)");
|
|
2994
4093
|
await this.query(`
|
|
2995
4094
|
create table if not exists artifacts(
|
|
2996
4095
|
hash text primary key,
|
|
@@ -3032,6 +4131,24 @@ export class PostgresAnonymousStore {
|
|
|
3032
4131
|
primary key (deploy_id, bucket, window_start)
|
|
3033
4132
|
)
|
|
3034
4133
|
`);
|
|
4134
|
+
await this.query(`
|
|
4135
|
+
create table if not exists deploy_create_quota_events(
|
|
4136
|
+
client_key text not null,
|
|
4137
|
+
bucket text not null,
|
|
4138
|
+
window_start timestamptz not null,
|
|
4139
|
+
count integer not null,
|
|
4140
|
+
primary key (client_key, bucket, window_start)
|
|
4141
|
+
)
|
|
4142
|
+
`);
|
|
4143
|
+
await this.query(`
|
|
4144
|
+
create table if not exists client_quota_events(
|
|
4145
|
+
client_key text not null,
|
|
4146
|
+
bucket text not null,
|
|
4147
|
+
window_start timestamptz not null,
|
|
4148
|
+
count integer not null,
|
|
4149
|
+
primary key (client_key, bucket, window_start)
|
|
4150
|
+
)
|
|
4151
|
+
`);
|
|
3035
4152
|
await this.query(`
|
|
3036
4153
|
create table if not exists deploy_server_env(
|
|
3037
4154
|
deploy_id text not null references deploys(id) on delete cascade,
|
|
@@ -3056,6 +4173,14 @@ export class PostgresAnonymousStore {
|
|
|
3056
4173
|
updated_at timestamptz not null
|
|
3057
4174
|
)
|
|
3058
4175
|
`);
|
|
4176
|
+
await this.query(`
|
|
4177
|
+
create table if not exists cleanup_runs(
|
|
4178
|
+
id bigserial primary key,
|
|
4179
|
+
started_at timestamptz not null,
|
|
4180
|
+
finished_at timestamptz not null,
|
|
4181
|
+
stats_json jsonb not null
|
|
4182
|
+
)
|
|
4183
|
+
`);
|
|
3059
4184
|
await this.query("alter table users add column if not exists boost boolean not null default false");
|
|
3060
4185
|
await this.query(`
|
|
3061
4186
|
insert into users(
|
|
@@ -3096,6 +4221,33 @@ export class PostgresAnonymousStore {
|
|
|
3096
4221
|
return this.pool.query(sql, params);
|
|
3097
4222
|
}
|
|
3098
4223
|
|
|
4224
|
+
async withPostgresTransaction(handler) {
|
|
4225
|
+
if (!this.pool) {
|
|
4226
|
+
throw new Error("Postgres store is not initialized.");
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
const client = await this.pool.connect();
|
|
4230
|
+
try {
|
|
4231
|
+
await client.query("begin");
|
|
4232
|
+
const result = await handler(client);
|
|
4233
|
+
await client.query("commit");
|
|
4234
|
+
return result;
|
|
4235
|
+
} catch (error) {
|
|
4236
|
+
try {
|
|
4237
|
+
await client.query("rollback");
|
|
4238
|
+
} catch {
|
|
4239
|
+
// Preserve the original transaction error.
|
|
4240
|
+
}
|
|
4241
|
+
throw error;
|
|
4242
|
+
} finally {
|
|
4243
|
+
client.release();
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
|
|
4247
|
+
async lockHostnameAllocation(client, hostname) {
|
|
4248
|
+
await client.query("select pg_advisory_xact_lock(hashtext('lakebed.deploy_hostname'), hashtext($1))", [hostname]);
|
|
4249
|
+
}
|
|
4250
|
+
|
|
3099
4251
|
rowToUser(row) {
|
|
3100
4252
|
if (!row) {
|
|
3101
4253
|
return null;
|
|
@@ -3320,6 +4472,25 @@ export class PostgresAnonymousStore {
|
|
|
3320
4472
|
);
|
|
3321
4473
|
}
|
|
3322
4474
|
|
|
4475
|
+
async decrementArtifactRef(artifactHash) {
|
|
4476
|
+
const result = await this.query(
|
|
4477
|
+
`
|
|
4478
|
+
update artifacts
|
|
4479
|
+
set ref_count = greatest(ref_count - 1, 0)
|
|
4480
|
+
where hash = $1
|
|
4481
|
+
returning ref_count
|
|
4482
|
+
`,
|
|
4483
|
+
[artifactHash]
|
|
4484
|
+
);
|
|
4485
|
+
const refCount = result.rows[0]?.ref_count;
|
|
4486
|
+
if (refCount === undefined || Number(refCount) > 0) {
|
|
4487
|
+
return false;
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
await this.query("delete from artifacts where hash = $1 and ref_count <= 0", [artifactHash]);
|
|
4491
|
+
return true;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
3323
4494
|
async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, serverEnv }) {
|
|
3324
4495
|
const createdAt = now();
|
|
3325
4496
|
const normalizedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
@@ -3331,6 +4502,8 @@ export class PostgresAnonymousStore {
|
|
|
3331
4502
|
|
|
3332
4503
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
3333
4504
|
const slug = createSlug();
|
|
4505
|
+
const generatedHostname = normalizedAppBaseDomain ? `${slug}.${normalizedAppBaseDomain}` : null;
|
|
4506
|
+
|
|
3334
4507
|
const url = appUrlForSlug({ appBaseDomain: normalizedAppBaseDomain, publicRootUrl, slug });
|
|
3335
4508
|
const deploy = {
|
|
3336
4509
|
appBaseDomain: normalizedAppBaseDomain,
|
|
@@ -3352,30 +4525,44 @@ export class PostgresAnonymousStore {
|
|
|
3352
4525
|
};
|
|
3353
4526
|
|
|
3354
4527
|
try {
|
|
3355
|
-
await this.
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
4528
|
+
const inserted = await this.withPostgresTransaction(async (client) => {
|
|
4529
|
+
if (generatedHostname) {
|
|
4530
|
+
await this.lockHostnameAllocation(client, generatedHostname);
|
|
4531
|
+
const domainConflict = await client.query("select 1 from deploy_domains where hostname = $1", [generatedHostname]);
|
|
4532
|
+
if (domainConflict.rows.length > 0) {
|
|
4533
|
+
return false;
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
await client.query(
|
|
4538
|
+
`
|
|
4539
|
+
insert into deploys(
|
|
4540
|
+
id, slug, status, artifact_hash, client_bundle_hash, created_at, updated_at, expires_at,
|
|
4541
|
+
claim_token_hash, limits_json, counters_json, public_root_url, app_base_domain, url
|
|
4542
|
+
)
|
|
4543
|
+
values($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, '{}'::jsonb, $11, $12, $13)
|
|
4544
|
+
`,
|
|
4545
|
+
[
|
|
4546
|
+
deploy.id,
|
|
4547
|
+
deploy.slug,
|
|
4548
|
+
deploy.status,
|
|
4549
|
+
deploy.artifactHash,
|
|
4550
|
+
deploy.clientBundleHash,
|
|
4551
|
+
deploy.createdAt,
|
|
4552
|
+
deploy.updatedAt,
|
|
4553
|
+
deploy.expiresAt,
|
|
4554
|
+
deploy.claimTokenHash,
|
|
4555
|
+
JSON.stringify(deploy.limits),
|
|
4556
|
+
deploy.publicRootUrl,
|
|
4557
|
+
deploy.appBaseDomain,
|
|
4558
|
+
deploy.url
|
|
4559
|
+
]
|
|
4560
|
+
);
|
|
4561
|
+
return true;
|
|
4562
|
+
});
|
|
4563
|
+
if (!inserted) {
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
3379
4566
|
if (serverEnv !== undefined) {
|
|
3380
4567
|
await this.replaceServerEnv(deploy.id, serverEnv, createdAt);
|
|
3381
4568
|
}
|
|
@@ -3431,6 +4618,7 @@ export class PostgresAnonymousStore {
|
|
|
3431
4618
|
if (serverEnv !== undefined) {
|
|
3432
4619
|
await this.replaceServerEnv(deployId, serverEnv, updatedAt);
|
|
3433
4620
|
}
|
|
4621
|
+
await this.decrementArtifactRef(currentDeploy.artifactHash);
|
|
3434
4622
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3435
4623
|
}
|
|
3436
4624
|
|
|
@@ -3499,7 +4687,7 @@ export class PostgresAnonymousStore {
|
|
|
3499
4687
|
createdAt: new Date(row.created_at).toISOString(),
|
|
3500
4688
|
expiresAt: row.expires_at ? new Date(row.expires_at).toISOString() : null,
|
|
3501
4689
|
id: row.id,
|
|
3502
|
-
limits: row.limits_json,
|
|
4690
|
+
limits: { ...DEFAULT_ANONYMOUS_LIMITS, ...(row.limits_json ?? {}) },
|
|
3503
4691
|
owner: row.owner_json ?? null,
|
|
3504
4692
|
ownerId: row.owner_id ?? null,
|
|
3505
4693
|
publicRootUrl: row.public_root_url,
|
|
@@ -3510,6 +4698,24 @@ export class PostgresAnonymousStore {
|
|
|
3510
4698
|
};
|
|
3511
4699
|
}
|
|
3512
4700
|
|
|
4701
|
+
rowToDeployDomain(row) {
|
|
4702
|
+
if (!row) {
|
|
4703
|
+
return null;
|
|
4704
|
+
}
|
|
4705
|
+
|
|
4706
|
+
return {
|
|
4707
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
4708
|
+
deployId: row.deploy_id,
|
|
4709
|
+
hostname: row.hostname,
|
|
4710
|
+
isPrimary: Boolean(row.is_primary),
|
|
4711
|
+
kind: row.kind,
|
|
4712
|
+
ownerId: row.owner_id,
|
|
4713
|
+
status: row.status,
|
|
4714
|
+
updatedAt: new Date(row.updated_at).toISOString(),
|
|
4715
|
+
url: `https://${row.hostname}`
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
|
|
3513
4719
|
async getDeployById(id) {
|
|
3514
4720
|
return this.deployWithUserLimitOverrides(await this.getBaseDeployById(id));
|
|
3515
4721
|
}
|
|
@@ -3519,6 +4725,80 @@ export class PostgresAnonymousStore {
|
|
|
3519
4725
|
return this.deployWithUserLimitOverrides(this.rowToDeploy(result.rows[0]));
|
|
3520
4726
|
}
|
|
3521
4727
|
|
|
4728
|
+
async getDeployDomainByHostname(hostname) {
|
|
4729
|
+
const result = await this.query("select * from deploy_domains where hostname = $1", [hostname]);
|
|
4730
|
+
return this.rowToDeployDomain(result.rows[0]);
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
async listDeployDomainsForDeploy(deployId) {
|
|
4734
|
+
const result = await this.query("select * from deploy_domains where deploy_id = $1 order by hostname asc", [deployId]);
|
|
4735
|
+
return result.rows.map((row) => this.rowToDeployDomain(row));
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4738
|
+
async createLakebedSubdomain({ deployId, hostname, label, ownerId }) {
|
|
4739
|
+
try {
|
|
4740
|
+
return await this.withPostgresTransaction(async (client) => {
|
|
4741
|
+
const deployResult = await client.query("select * from deploys where id = $1 for update", [deployId]);
|
|
4742
|
+
const deploy = this.rowToDeploy(deployResult.rows[0]);
|
|
4743
|
+
if (!deploy) {
|
|
4744
|
+
return { status: "missing" };
|
|
4745
|
+
}
|
|
4746
|
+
if (!deploy.ownerId) {
|
|
4747
|
+
return { deploy, status: "unclaimed" };
|
|
4748
|
+
}
|
|
4749
|
+
if (deploy.ownerId !== ownerId) {
|
|
4750
|
+
return { deploy, status: "forbidden" };
|
|
4751
|
+
}
|
|
4752
|
+
if (deploy.status !== "active" || isExpired(deploy)) {
|
|
4753
|
+
return { deploy, status: "inactive" };
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
await this.lockHostnameAllocation(client, hostname);
|
|
4757
|
+
|
|
4758
|
+
const existingResult = await client.query("select * from deploy_domains where hostname = $1", [hostname]);
|
|
4759
|
+
const existing = this.rowToDeployDomain(existingResult.rows[0]);
|
|
4760
|
+
if (existing) {
|
|
4761
|
+
if (existing.deployId === deployId) {
|
|
4762
|
+
return { deploy, domain: existing, status: "exists" };
|
|
4763
|
+
}
|
|
4764
|
+
|
|
4765
|
+
return { deploy, domain: existing, status: "conflict" };
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
const slugConflict = await client.query("select id from deploys where slug = $1 limit 1", [label]);
|
|
4769
|
+
if (slugConflict.rows.length > 0) {
|
|
4770
|
+
return { deploy, status: "slug_conflict" };
|
|
4771
|
+
}
|
|
4772
|
+
|
|
4773
|
+
const timestamp = now();
|
|
4774
|
+
const insertResult = await client.query(
|
|
4775
|
+
`
|
|
4776
|
+
insert into deploy_domains(hostname, deploy_id, owner_id, kind, status, is_primary, created_at, updated_at)
|
|
4777
|
+
values($1, $2, $3, 'lakebed_subdomain', 'active', false, $4, $4)
|
|
4778
|
+
returning *
|
|
4779
|
+
`,
|
|
4780
|
+
[hostname, deployId, ownerId, timestamp]
|
|
4781
|
+
);
|
|
4782
|
+
return { deploy, domain: this.rowToDeployDomain(insertResult.rows[0]), status: "created" };
|
|
4783
|
+
});
|
|
4784
|
+
} catch (error) {
|
|
4785
|
+
if (error?.code !== "23505") {
|
|
4786
|
+
throw error;
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
const deploy = await this.getBaseDeployById(deployId);
|
|
4790
|
+
if (!deploy) {
|
|
4791
|
+
return { status: "missing" };
|
|
4792
|
+
}
|
|
4793
|
+
const conflict = await this.getDeployDomainByHostname(hostname);
|
|
4794
|
+
if (conflict?.deployId === deployId) {
|
|
4795
|
+
return { deploy, domain: conflict, status: "exists" };
|
|
4796
|
+
}
|
|
4797
|
+
|
|
4798
|
+
return { deploy, domain: conflict, status: "conflict" };
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
|
|
3522
4802
|
async getArtifact(hash) {
|
|
3523
4803
|
const result = await this.query("select * from artifacts where hash = $1", [hash]);
|
|
3524
4804
|
const row = result.rows[0];
|
|
@@ -3599,8 +4879,46 @@ export class PostgresAnonymousStore {
|
|
|
3599
4879
|
return Object.fromEntries(result.rows.map((row) => [row.env_key, decryptServerEnvValue(row.env_value, this.serverEnvSecret)]));
|
|
3600
4880
|
}
|
|
3601
4881
|
|
|
3602
|
-
async
|
|
3603
|
-
const
|
|
4882
|
+
async stateResourceForDeploy(deployId) {
|
|
4883
|
+
const result = await this.query(
|
|
4884
|
+
`
|
|
4885
|
+
select
|
|
4886
|
+
count(*)::int as state_rows,
|
|
4887
|
+
coalesce(sum(octet_length(data_json::text)), 0)::int as state_bytes
|
|
4888
|
+
from state_rows
|
|
4889
|
+
where deploy_id = $1
|
|
4890
|
+
`,
|
|
4891
|
+
[deployId]
|
|
4892
|
+
);
|
|
4893
|
+
return {
|
|
4894
|
+
stateBytes: result.rows[0]?.state_bytes ?? 0,
|
|
4895
|
+
stateRows: result.rows[0]?.state_rows ?? 0
|
|
4896
|
+
};
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
async assertStateWithinLimits(deployId, options) {
|
|
4900
|
+
assertStateResourceLimits(await this.stateResourceForDeploy(deployId), options);
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
async transaction(deployId, handler, options = {}) {
|
|
4904
|
+
const run = async () => {
|
|
4905
|
+
const client = await this.pool.connect();
|
|
4906
|
+
const tx = Object.create(this);
|
|
4907
|
+
tx.query = (sql, params = []) => client.query(sql, params);
|
|
4908
|
+
tx.pool = this.pool;
|
|
4909
|
+
try {
|
|
4910
|
+
await client.query("begin");
|
|
4911
|
+
const result = await handler(tx);
|
|
4912
|
+
await tx.assertStateWithinLimits(deployId, options);
|
|
4913
|
+
await client.query("commit");
|
|
4914
|
+
return result;
|
|
4915
|
+
} catch (error) {
|
|
4916
|
+
await client.query("rollback").catch(() => undefined);
|
|
4917
|
+
throw error;
|
|
4918
|
+
} finally {
|
|
4919
|
+
client.release();
|
|
4920
|
+
}
|
|
4921
|
+
};
|
|
3604
4922
|
const next = (this.queues.get(deployId) ?? Promise.resolve()).then(run, run);
|
|
3605
4923
|
this.queues.set(
|
|
3606
4924
|
deployId,
|
|
@@ -3613,9 +4931,29 @@ export class PostgresAnonymousStore {
|
|
|
3613
4931
|
}
|
|
3614
4932
|
|
|
3615
4933
|
async appendLog(deployId, level, message, data) {
|
|
4934
|
+
const entry = normalizeLogEntry(level, message, data);
|
|
3616
4935
|
await this.query(
|
|
3617
4936
|
"insert into logs(deploy_id, level, message, data_json, created_at) values($1, $2, $3, $4::jsonb, $5)",
|
|
3618
|
-
[deployId, level, message, JSON.stringify(data ?? null),
|
|
4937
|
+
[deployId, entry.level, entry.message, JSON.stringify(entry.data ?? null), entry.at]
|
|
4938
|
+
);
|
|
4939
|
+
await this.query(
|
|
4940
|
+
`
|
|
4941
|
+
delete from logs
|
|
4942
|
+
where deploy_id = $1
|
|
4943
|
+
and sequence in (
|
|
4944
|
+
select sequence
|
|
4945
|
+
from (
|
|
4946
|
+
select
|
|
4947
|
+
sequence,
|
|
4948
|
+
row_number() over (order by sequence desc) as row_number,
|
|
4949
|
+
sum(octet_length(message) + octet_length(coalesce(data_json::text, ''))) over (order by sequence desc) as cumulative_bytes
|
|
4950
|
+
from logs
|
|
4951
|
+
where deploy_id = $1
|
|
4952
|
+
) ranked
|
|
4953
|
+
where row_number > $2 or cumulative_bytes > $3
|
|
4954
|
+
)
|
|
4955
|
+
`,
|
|
4956
|
+
[deployId, DEFAULT_ANONYMOUS_LIMITS.logEntries, DEFAULT_ANONYMOUS_LIMITS.logBytes]
|
|
3619
4957
|
);
|
|
3620
4958
|
}
|
|
3621
4959
|
|
|
@@ -3668,6 +5006,10 @@ export class PostgresAnonymousStore {
|
|
|
3668
5006
|
}
|
|
3669
5007
|
|
|
3670
5008
|
async incrementQuota(deployId, bucket, limit) {
|
|
5009
|
+
if (!Number.isFinite(limit)) {
|
|
5010
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5011
|
+
}
|
|
5012
|
+
|
|
3671
5013
|
const windowStart = dayWindowStart();
|
|
3672
5014
|
const result = await this.query(
|
|
3673
5015
|
`
|
|
@@ -3681,7 +5023,74 @@ export class PostgresAnonymousStore {
|
|
|
3681
5023
|
);
|
|
3682
5024
|
const count = result.rows[0].count;
|
|
3683
5025
|
if (count > limit) {
|
|
3684
|
-
throw new
|
|
5026
|
+
throw new LakebedQuotaError({
|
|
5027
|
+
bucket,
|
|
5028
|
+
count,
|
|
5029
|
+
limit,
|
|
5030
|
+
resetAt: quotaResetAt(windowStart),
|
|
5031
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5032
|
+
suggestion: bucket === "mutations" ? "Retry after reset, reduce mutation frequency, or claim the deploy." : "Retry after reset or reduce request frequency."
|
|
5033
|
+
});
|
|
5034
|
+
}
|
|
5035
|
+
return { bucket, count, limit, windowStart };
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
async incrementDeployCreateQuota(clientKey, bucket, limit) {
|
|
5039
|
+
if (!Number.isFinite(limit)) {
|
|
5040
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
const windowStart = dayWindowStart();
|
|
5044
|
+
const result = await this.query(
|
|
5045
|
+
`
|
|
5046
|
+
insert into deploy_create_quota_events(client_key, bucket, window_start, count)
|
|
5047
|
+
values($1, $2, $3, 1)
|
|
5048
|
+
on conflict(client_key, bucket, window_start)
|
|
5049
|
+
do update set count = deploy_create_quota_events.count + 1
|
|
5050
|
+
returning count
|
|
5051
|
+
`,
|
|
5052
|
+
[clientKey, bucket, windowStart]
|
|
5053
|
+
);
|
|
5054
|
+
const count = result.rows[0].count;
|
|
5055
|
+
if (count > limit) {
|
|
5056
|
+
throw new LakebedQuotaError({
|
|
5057
|
+
bucket,
|
|
5058
|
+
count,
|
|
5059
|
+
limit,
|
|
5060
|
+
resetAt: quotaResetAt(windowStart),
|
|
5061
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5062
|
+
suggestion: "Retry after reset, reuse an existing deploy, or sign in and claim deploys you want to keep."
|
|
5063
|
+
});
|
|
5064
|
+
}
|
|
5065
|
+
return { bucket, count, limit, windowStart };
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
async incrementClientQuota(clientKey, bucket, limit) {
|
|
5069
|
+
if (!Number.isFinite(limit)) {
|
|
5070
|
+
return { bucket, count: 0, limit, windowStart: dayWindowStart() };
|
|
5071
|
+
}
|
|
5072
|
+
|
|
5073
|
+
const windowStart = dayWindowStart();
|
|
5074
|
+
const result = await this.query(
|
|
5075
|
+
`
|
|
5076
|
+
insert into client_quota_events(client_key, bucket, window_start, count)
|
|
5077
|
+
values($1, $2, $3, 1)
|
|
5078
|
+
on conflict(client_key, bucket, window_start)
|
|
5079
|
+
do update set count = client_quota_events.count + 1
|
|
5080
|
+
returning count
|
|
5081
|
+
`,
|
|
5082
|
+
[clientKey, bucket, windowStart]
|
|
5083
|
+
);
|
|
5084
|
+
const count = result.rows[0].count;
|
|
5085
|
+
if (count > limit) {
|
|
5086
|
+
throw new LakebedQuotaError({
|
|
5087
|
+
bucket,
|
|
5088
|
+
count,
|
|
5089
|
+
limit,
|
|
5090
|
+
resetAt: quotaResetAt(windowStart),
|
|
5091
|
+
retryAfterSeconds: quotaRetryAfterSeconds(windowStart),
|
|
5092
|
+
suggestion: clientTrafficQuotaSuggestion(bucket)
|
|
5093
|
+
});
|
|
3685
5094
|
}
|
|
3686
5095
|
return { bucket, count, limit, windowStart };
|
|
3687
5096
|
}
|
|
@@ -3795,6 +5204,109 @@ export class PostgresAnonymousStore {
|
|
|
3795
5204
|
})
|
|
3796
5205
|
);
|
|
3797
5206
|
}
|
|
5207
|
+
|
|
5208
|
+
async recordCleanupRun(stats) {
|
|
5209
|
+
await this.query(
|
|
5210
|
+
"insert into cleanup_runs(started_at, finished_at, stats_json) values($1, $2, $3::jsonb)",
|
|
5211
|
+
[stats.startedAt, stats.finishedAt, JSON.stringify(stats)]
|
|
5212
|
+
);
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5215
|
+
async cleanupExpiredDeploys({ graceSeconds = 60 * 60, retentionSeconds = 7 * 24 * 60 * 60, nowMs = Date.now() } = {}) {
|
|
5216
|
+
const startedAt = new Date(nowMs).toISOString();
|
|
5217
|
+
const finishedAt = now();
|
|
5218
|
+
const markCutoff = new Date(nowMs - graceSeconds * 1000).toISOString();
|
|
5219
|
+
const deleteCutoff = new Date(nowMs - retentionSeconds * 1000).toISOString();
|
|
5220
|
+
const examined = await this.query("select count(*)::int as count from deploys");
|
|
5221
|
+
const marked = await this.query(
|
|
5222
|
+
`
|
|
5223
|
+
update deploys
|
|
5224
|
+
set status = 'terminated',
|
|
5225
|
+
updated_at = $2
|
|
5226
|
+
where owner_id is null
|
|
5227
|
+
and expires_at is not null
|
|
5228
|
+
and expires_at <= $1
|
|
5229
|
+
and status = 'active'
|
|
5230
|
+
returning id
|
|
5231
|
+
`,
|
|
5232
|
+
[markCutoff, finishedAt]
|
|
5233
|
+
);
|
|
5234
|
+
const candidates = await this.query(
|
|
5235
|
+
`
|
|
5236
|
+
select id, artifact_hash
|
|
5237
|
+
from deploys
|
|
5238
|
+
where owner_id is null
|
|
5239
|
+
and expires_at is not null
|
|
5240
|
+
and expires_at <= $1
|
|
5241
|
+
`,
|
|
5242
|
+
[deleteCutoff]
|
|
5243
|
+
);
|
|
5244
|
+
const stats = {
|
|
5245
|
+
deletedArtifacts: 0,
|
|
5246
|
+
deletedDeploys: 0,
|
|
5247
|
+
deletedLogEntries: 0,
|
|
5248
|
+
deletedQuotaEvents: 0,
|
|
5249
|
+
deletedServerEnv: 0,
|
|
5250
|
+
deletedStateRows: 0,
|
|
5251
|
+
examinedDeploys: examined.rows[0]?.count ?? 0,
|
|
5252
|
+
finishedAt,
|
|
5253
|
+
markedTerminated: marked.rowCount,
|
|
5254
|
+
startedAt
|
|
5255
|
+
};
|
|
5256
|
+
|
|
5257
|
+
for (const deploy of candidates.rows) {
|
|
5258
|
+
const stateRows = await this.query("delete from state_rows where deploy_id = $1", [deploy.id]);
|
|
5259
|
+
const logs = await this.query("delete from logs where deploy_id = $1", [deploy.id]);
|
|
5260
|
+
const quota = await this.query("delete from quota_events where deploy_id = $1", [deploy.id]);
|
|
5261
|
+
const serverEnv = await this.query("delete from deploy_server_env where deploy_id = $1", [deploy.id]);
|
|
5262
|
+
await this.query("delete from deploys where id = $1", [deploy.id]);
|
|
5263
|
+
stats.deletedDeploys += 1;
|
|
5264
|
+
stats.deletedStateRows += stateRows.rowCount;
|
|
5265
|
+
stats.deletedLogEntries += logs.rowCount;
|
|
5266
|
+
stats.deletedQuotaEvents += quota.rowCount;
|
|
5267
|
+
stats.deletedServerEnv += serverEnv.rowCount;
|
|
5268
|
+
stats.deletedArtifacts += (await this.decrementArtifactRef(deploy.artifact_hash)) ? 1 : 0;
|
|
5269
|
+
}
|
|
5270
|
+
const deployCreateQuota = await this.query("delete from deploy_create_quota_events where window_start <= $1", [deleteCutoff]);
|
|
5271
|
+
stats.deletedQuotaEvents += deployCreateQuota.rowCount;
|
|
5272
|
+
const clientQuota = await this.query("delete from client_quota_events where window_start <= $1", [deleteCutoff]);
|
|
5273
|
+
stats.deletedQuotaEvents += clientQuota.rowCount;
|
|
5274
|
+
|
|
5275
|
+
await this.recordCleanupRun(stats);
|
|
5276
|
+
return stats;
|
|
5277
|
+
}
|
|
5278
|
+
|
|
5279
|
+
async readCleanupActivity() {
|
|
5280
|
+
const result = await this.query("select stats_json from cleanup_runs order by id desc limit 20");
|
|
5281
|
+
const recentRuns = result.rows.map((row) => row.stats_json);
|
|
5282
|
+
const totalsResult = await this.query(`
|
|
5283
|
+
select
|
|
5284
|
+
coalesce(sum((stats_json->>'deletedArtifacts')::int), 0)::int as deleted_artifacts,
|
|
5285
|
+
coalesce(sum((stats_json->>'deletedDeploys')::int), 0)::int as deleted_deploys,
|
|
5286
|
+
coalesce(sum((stats_json->>'deletedLogEntries')::int), 0)::int as deleted_log_entries,
|
|
5287
|
+
coalesce(sum((stats_json->>'deletedQuotaEvents')::int), 0)::int as deleted_quota_events,
|
|
5288
|
+
coalesce(sum((stats_json->>'deletedServerEnv')::int), 0)::int as deleted_server_env,
|
|
5289
|
+
coalesce(sum((stats_json->>'deletedStateRows')::int), 0)::int as deleted_state_rows,
|
|
5290
|
+
coalesce(sum((stats_json->>'examinedDeploys')::int), 0)::int as examined_deploys,
|
|
5291
|
+
coalesce(sum((stats_json->>'markedTerminated')::int), 0)::int as marked_terminated
|
|
5292
|
+
from cleanup_runs
|
|
5293
|
+
`);
|
|
5294
|
+
const totalsRow = totalsResult.rows[0] ?? {};
|
|
5295
|
+
return {
|
|
5296
|
+
lastRun: recentRuns[0] ?? null,
|
|
5297
|
+
recentRuns,
|
|
5298
|
+
totals: {
|
|
5299
|
+
deletedArtifacts: totalsRow.deleted_artifacts ?? 0,
|
|
5300
|
+
deletedDeploys: totalsRow.deleted_deploys ?? 0,
|
|
5301
|
+
deletedLogEntries: totalsRow.deleted_log_entries ?? 0,
|
|
5302
|
+
deletedQuotaEvents: totalsRow.deleted_quota_events ?? 0,
|
|
5303
|
+
deletedServerEnv: totalsRow.deleted_server_env ?? 0,
|
|
5304
|
+
deletedStateRows: totalsRow.deleted_state_rows ?? 0,
|
|
5305
|
+
examinedDeploys: totalsRow.examined_deploys ?? 0,
|
|
5306
|
+
markedTerminated: totalsRow.marked_terminated ?? 0
|
|
5307
|
+
}
|
|
5308
|
+
};
|
|
5309
|
+
}
|
|
3798
5310
|
}
|
|
3799
5311
|
|
|
3800
5312
|
export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
@@ -3813,12 +5325,30 @@ export async function createAnonymousStoreFromEnv(env = process.env) {
|
|
|
3813
5325
|
}
|
|
3814
5326
|
|
|
3815
5327
|
async function loadDeployByRoute({ appBaseDomain, host, store, url }) {
|
|
3816
|
-
const
|
|
5328
|
+
const hostname = hostnameFromHost(host);
|
|
5329
|
+
const domain = hostname && typeof store.getDeployDomainByHostname === "function" ? await store.getDeployDomainByHostname(hostname) : null;
|
|
5330
|
+
const route = domain
|
|
5331
|
+
? {
|
|
5332
|
+
appPath: url.pathname || "/",
|
|
5333
|
+
basePath: "",
|
|
5334
|
+
domain,
|
|
5335
|
+
hostname: domain.hostname,
|
|
5336
|
+
deployId: domain.deployId
|
|
5337
|
+
}
|
|
5338
|
+
: parseHostDeploy({ appBaseDomain, host, url }) ?? parsePathDeploy(url);
|
|
3817
5339
|
if (!route) {
|
|
3818
5340
|
return null;
|
|
3819
5341
|
}
|
|
3820
5342
|
|
|
3821
|
-
|
|
5343
|
+
if (domain?.status && domain.status !== "active") {
|
|
5344
|
+
return {
|
|
5345
|
+
error: domain.status === "disabled" ? "Lakebed subdomain is disabled." : "Lakebed subdomain is not active.",
|
|
5346
|
+
route,
|
|
5347
|
+
status: domain.status === "disabled" ? 410 : 404
|
|
5348
|
+
};
|
|
5349
|
+
}
|
|
5350
|
+
|
|
5351
|
+
const deploy = route.deployId ? await store.getDeployById(route.deployId) : await store.getDeployBySlug(route.slug);
|
|
3822
5352
|
if (!deploy) {
|
|
3823
5353
|
return { error: "Unknown anonymous deploy.", route, status: 404 };
|
|
3824
5354
|
}
|
|
@@ -3845,6 +5375,10 @@ async function serveInspect({ artifact, deploy, route, store, systemPath }, res)
|
|
|
3845
5375
|
artifactHash: deploy.artifactHash,
|
|
3846
5376
|
clientBundleHash: deploy.clientBundleHash,
|
|
3847
5377
|
deployId: deploy.id,
|
|
5378
|
+
domains:
|
|
5379
|
+
typeof store.listDeployDomainsForDeploy === "function"
|
|
5380
|
+
? (await store.listDeployDomainsForDeploy(deploy.id)).map(responseForDeployDomain)
|
|
5381
|
+
: [],
|
|
3848
5382
|
expiresAt: deploy.expiresAt,
|
|
3849
5383
|
limits: deploy.limits,
|
|
3850
5384
|
mutations: Object.keys(artifact.server.mutations ?? {}),
|
|
@@ -3910,6 +5444,9 @@ export async function startAnonymousServer({
|
|
|
3910
5444
|
} = {}) {
|
|
3911
5445
|
const resolvedAppBaseDomain = normalizeAppBaseDomain(appBaseDomain);
|
|
3912
5446
|
const resolvedPublicRootUrl = normalizePublicRootUrl(publicRootUrl ?? process.env.PUBLIC_ROOT_URL, port);
|
|
5447
|
+
const deployCreationPolicy = anonymousDeployCreationPolicy({ publicRootUrl: resolvedPublicRootUrl });
|
|
5448
|
+
const clientTrafficPolicy = anonymousClientTrafficPolicy({ publicRootUrl: resolvedPublicRootUrl });
|
|
5449
|
+
const cleanupPolicy = cleanupPolicyFromEnv();
|
|
3913
5450
|
const resolvedGithubOAuth = normalizeGithubOAuth(githubOAuth);
|
|
3914
5451
|
const resolvedDeveloperSessionSecret =
|
|
3915
5452
|
developerSessionSecret || resolvedGithubOAuth?.sessionSecret || resolvedGithubOAuth?.clientSecret || adminPassword || "";
|
|
@@ -3917,6 +5454,7 @@ export async function startAnonymousServer({
|
|
|
3917
5454
|
const resolvedSourceRuntime = sourceRuntime === undefined ? createSourceRuntimeFromEnv() : sourceRuntime;
|
|
3918
5455
|
await resolvedStore.initialize();
|
|
3919
5456
|
const subscriptions = new Map();
|
|
5457
|
+
let cleanupInterval = null;
|
|
3920
5458
|
|
|
3921
5459
|
function activeConnectionCounts() {
|
|
3922
5460
|
const counts = new Map();
|
|
@@ -3937,6 +5475,10 @@ export async function startAnonymousServer({
|
|
|
3937
5475
|
async function adminSummary() {
|
|
3938
5476
|
const deploys = await adminDeploysWithConnections();
|
|
3939
5477
|
const users = await resolvedStore.listAdminUsers();
|
|
5478
|
+
const cleanup =
|
|
5479
|
+
typeof resolvedStore.readCleanupActivity === "function"
|
|
5480
|
+
? await resolvedStore.readCleanupActivity()
|
|
5481
|
+
: { lastRun: null, recentRuns: [], totals: {} };
|
|
3940
5482
|
const totals = deploys.reduce(
|
|
3941
5483
|
(acc, deploy) => ({
|
|
3942
5484
|
artifactBytes: acc.artifactBytes + deploy.artifactBytes,
|
|
@@ -3963,6 +5505,7 @@ export async function startAnonymousServer({
|
|
|
3963
5505
|
return {
|
|
3964
5506
|
deployCount: deploys.length,
|
|
3965
5507
|
deploys,
|
|
5508
|
+
cleanup,
|
|
3966
5509
|
generatedAt: now(),
|
|
3967
5510
|
totals,
|
|
3968
5511
|
userCount: users.length,
|
|
@@ -3989,10 +5532,50 @@ export async function startAnonymousServer({
|
|
|
3989
5532
|
return developerFromRequest(req, resolvedDeveloperSessionSecret);
|
|
3990
5533
|
}
|
|
3991
5534
|
|
|
5535
|
+
function canManageDeploy(req, deploy) {
|
|
5536
|
+
if (isDeployTokenValid(deploy, bearerToken(req))) {
|
|
5537
|
+
return true;
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
const user = currentDeveloper(req);
|
|
5541
|
+
return Boolean(user?.id && deploy.ownerId && user.id === deploy.ownerId);
|
|
5542
|
+
}
|
|
5543
|
+
|
|
3992
5544
|
async function developerDeploys(user) {
|
|
3993
5545
|
return resolvedStore.listDeploysForOwner(user.id);
|
|
3994
5546
|
}
|
|
3995
5547
|
|
|
5548
|
+
async function enforceAnonymousDeployCreation(req) {
|
|
5549
|
+
if (deployCreationPolicy.disabled) {
|
|
5550
|
+
const error = new LakebedQuotaError({
|
|
5551
|
+
bucket: "anonymous_deploy_create_global",
|
|
5552
|
+
count: deployCreationPolicy.globalLimit,
|
|
5553
|
+
limit: deployCreationPolicy.globalLimit,
|
|
5554
|
+
status: 503,
|
|
5555
|
+
suggestion: "Anonymous deploy creation is temporarily disabled. Retry later or claim an existing deploy."
|
|
5556
|
+
});
|
|
5557
|
+
error.code = "lakebed_deploy_creation_disabled";
|
|
5558
|
+
error.message = "Anonymous deploy creation is temporarily disabled.";
|
|
5559
|
+
throw error;
|
|
5560
|
+
}
|
|
5561
|
+
|
|
5562
|
+
const clientKey = forwardedClientKey(req);
|
|
5563
|
+
await resolvedStore.incrementDeployCreateQuota(clientKey, "anonymous_deploy_create_client", deployCreationPolicy.perClientLimit);
|
|
5564
|
+
await resolvedStore.incrementDeployCreateQuota("global", "anonymous_deploy_create_global", deployCreationPolicy.globalLimit);
|
|
5565
|
+
}
|
|
5566
|
+
|
|
5567
|
+
async function enforceClientTrafficQuota(clientKey, bucket) {
|
|
5568
|
+
if (typeof resolvedStore.incrementClientQuota !== "function") {
|
|
5569
|
+
return;
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5572
|
+
const limit =
|
|
5573
|
+
bucket === "mutations"
|
|
5574
|
+
? clientTrafficPolicy.mutationsPerClientLimit
|
|
5575
|
+
: clientTrafficPolicy.requestsPerClientLimit;
|
|
5576
|
+
await resolvedStore.incrementClientQuota(clientKey, clientTrafficQuotaBucket(bucket), limit);
|
|
5577
|
+
}
|
|
5578
|
+
|
|
3996
5579
|
async function refreshDeploySubscriptions(deploy) {
|
|
3997
5580
|
const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
|
|
3998
5581
|
if (!storedArtifact) {
|
|
@@ -4032,6 +5615,30 @@ export async function startAnonymousServer({
|
|
|
4032
5615
|
}
|
|
4033
5616
|
}
|
|
4034
5617
|
|
|
5618
|
+
function cleanupTouchedResources(stats) {
|
|
5619
|
+
return (
|
|
5620
|
+
Number(stats?.markedTerminated ?? 0) +
|
|
5621
|
+
Number(stats?.deletedDeploys ?? 0) +
|
|
5622
|
+
Number(stats?.deletedStateRows ?? 0) +
|
|
5623
|
+
Number(stats?.deletedLogEntries ?? 0) +
|
|
5624
|
+
Number(stats?.deletedArtifacts ?? 0)
|
|
5625
|
+
);
|
|
5626
|
+
}
|
|
5627
|
+
|
|
5628
|
+
async function runCleanup(reason = "periodic") {
|
|
5629
|
+
if (typeof resolvedStore.cleanupExpiredDeploys !== "function") {
|
|
5630
|
+
return null;
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
const stats = await resolvedStore.cleanupExpiredDeploys(cleanupPolicy);
|
|
5634
|
+
if (!quiet && cleanupTouchedResources(stats) > 0) {
|
|
5635
|
+
console.log(
|
|
5636
|
+
`[lakebed:cleanup] ${reason} marked=${stats.markedTerminated} deleted=${stats.deletedDeploys} stateRows=${stats.deletedStateRows} logs=${stats.deletedLogEntries} artifacts=${stats.deletedArtifacts}`
|
|
5637
|
+
);
|
|
5638
|
+
}
|
|
5639
|
+
return stats;
|
|
5640
|
+
}
|
|
5641
|
+
|
|
4035
5642
|
async function publishDeploy(deployId) {
|
|
4036
5643
|
for (const [ws, subscription] of subscriptions) {
|
|
4037
5644
|
if (subscription.deploy.id !== deployId) {
|
|
@@ -4378,7 +5985,92 @@ export async function startAnonymousServer({
|
|
|
4378
5985
|
return;
|
|
4379
5986
|
}
|
|
4380
5987
|
|
|
5988
|
+
const deployDomainsMatch = requestUrl.pathname.match(/^\/v1\/deploys\/([^/]+)\/domains$/);
|
|
5989
|
+
if (deployDomainsMatch && (req.method === "GET" || req.method === "POST")) {
|
|
5990
|
+
const deployId = decodeURIComponent(deployDomainsMatch[1]);
|
|
5991
|
+
const currentDeploy = await resolvedStore.getDeployById(deployId);
|
|
5992
|
+
if (!currentDeploy) {
|
|
5993
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
5994
|
+
return;
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
if (!canManageDeploy(req, currentDeploy)) {
|
|
5998
|
+
sendJson(res, 401, { error: "Invalid deploy token." });
|
|
5999
|
+
return;
|
|
6000
|
+
}
|
|
6001
|
+
|
|
6002
|
+
if (req.method === "GET") {
|
|
6003
|
+
sendJson(res, 200, {
|
|
6004
|
+
deployId: currentDeploy.id,
|
|
6005
|
+
domains:
|
|
6006
|
+
typeof resolvedStore.listDeployDomainsForDeploy === "function"
|
|
6007
|
+
? (await resolvedStore.listDeployDomainsForDeploy(currentDeploy.id)).map(responseForDeployDomain)
|
|
6008
|
+
: []
|
|
6009
|
+
});
|
|
6010
|
+
return;
|
|
6011
|
+
}
|
|
6012
|
+
|
|
6013
|
+
if (!currentDeploy.ownerId) {
|
|
6014
|
+
sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
|
|
6015
|
+
return;
|
|
6016
|
+
}
|
|
6017
|
+
if (currentDeploy.status !== "active" || isExpired(currentDeploy)) {
|
|
6018
|
+
sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
|
|
6019
|
+
return;
|
|
6020
|
+
}
|
|
6021
|
+
|
|
6022
|
+
let normalizedDomain;
|
|
6023
|
+
try {
|
|
6024
|
+
const body = await readJsonBody(req, 8192);
|
|
6025
|
+
normalizedDomain = normalizeLakebedSubdomainInput(body.hostname ?? body.subdomain, resolvedAppBaseDomain);
|
|
6026
|
+
} catch (error) {
|
|
6027
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
6028
|
+
return;
|
|
6029
|
+
}
|
|
6030
|
+
|
|
6031
|
+
const created = await resolvedStore.createLakebedSubdomain({
|
|
6032
|
+
deployId: currentDeploy.id,
|
|
6033
|
+
hostname: normalizedDomain.hostname,
|
|
6034
|
+
label: normalizedDomain.label,
|
|
6035
|
+
ownerId: currentDeploy.ownerId
|
|
6036
|
+
});
|
|
6037
|
+
if (created.status === "missing") {
|
|
6038
|
+
sendJson(res, 404, { error: "Unknown deploy." });
|
|
6039
|
+
return;
|
|
6040
|
+
}
|
|
6041
|
+
if (created.status === "unclaimed") {
|
|
6042
|
+
sendJson(res, 409, { error: "Deploy must be claimed before registering Lakebed subdomains." });
|
|
6043
|
+
return;
|
|
6044
|
+
}
|
|
6045
|
+
if (created.status === "forbidden") {
|
|
6046
|
+
sendJson(res, 403, { error: "Deploy is claimed by another developer." });
|
|
6047
|
+
return;
|
|
6048
|
+
}
|
|
6049
|
+
if (created.status === "inactive") {
|
|
6050
|
+
sendJson(res, 409, { error: "Deploy must be active to register Lakebed subdomains." });
|
|
6051
|
+
return;
|
|
6052
|
+
}
|
|
6053
|
+
if (created.status === "conflict") {
|
|
6054
|
+
sendJson(res, 409, { error: "Subdomain is already registered." });
|
|
6055
|
+
return;
|
|
6056
|
+
}
|
|
6057
|
+
if (created.status === "slug_conflict") {
|
|
6058
|
+
sendJson(res, 409, { error: "Subdomain is already used by a generated Lakebed deploy URL." });
|
|
6059
|
+
return;
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
if (created.status === "created") {
|
|
6063
|
+
await resolvedStore.appendLog(currentDeploy.id, "info", "lakebed subdomain registered", {
|
|
6064
|
+
hostname: created.domain.hostname
|
|
6065
|
+
});
|
|
6066
|
+
}
|
|
6067
|
+
|
|
6068
|
+
sendJson(res, created.status === "created" ? 201 : 200, responseForDeployDomain(created.domain));
|
|
6069
|
+
return;
|
|
6070
|
+
}
|
|
6071
|
+
|
|
4381
6072
|
if (req.method === "POST" && requestUrl.pathname === "/v1/anonymous-deploys") {
|
|
6073
|
+
await enforceAnonymousDeployCreation(req);
|
|
4382
6074
|
const body = await readJsonBody(req);
|
|
4383
6075
|
const payload = validateAnonymousDeployPayload(body);
|
|
4384
6076
|
if (payload.serverEnv !== undefined && Object.keys(payload.serverEnv).length > 0) {
|
|
@@ -4463,6 +6155,8 @@ export async function startAnonymousServer({
|
|
|
4463
6155
|
return;
|
|
4464
6156
|
}
|
|
4465
6157
|
|
|
6158
|
+
const clientKey = forwardedClientKey(req);
|
|
6159
|
+
await enforceClientTrafficQuota(clientKey, "requests");
|
|
4466
6160
|
await resolvedStore.incrementQuota(
|
|
4467
6161
|
loaded.deploy.id,
|
|
4468
6162
|
"requests",
|
|
@@ -4492,16 +6186,28 @@ export async function startAnonymousServer({
|
|
|
4492
6186
|
|
|
4493
6187
|
sendText(res, 404, "Not found\n", { "Content-Type": "text/plain; charset=utf-8" });
|
|
4494
6188
|
} catch (error) {
|
|
6189
|
+
if (isQuotaError(error)) {
|
|
6190
|
+
sendJson(
|
|
6191
|
+
res,
|
|
6192
|
+
error.status ?? 429,
|
|
6193
|
+
quotaErrorBody(error, {
|
|
6194
|
+
signInUrl: `${resolvedPublicRootUrl}/auth/github?return_to=${encodeURIComponent("/deploys")}`
|
|
6195
|
+
}),
|
|
6196
|
+
quotaErrorHeaders(error)
|
|
6197
|
+
);
|
|
6198
|
+
return;
|
|
6199
|
+
}
|
|
4495
6200
|
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
4496
6201
|
}
|
|
4497
6202
|
});
|
|
4498
6203
|
|
|
4499
6204
|
const wss = new WebSocketServer({ noServer: true });
|
|
4500
6205
|
|
|
4501
|
-
wss.on("connection", (ws,
|
|
6206
|
+
wss.on("connection", (ws, req, loaded, auth) => {
|
|
4502
6207
|
subscriptions.set(ws, {
|
|
4503
6208
|
artifact: loaded.artifact,
|
|
4504
6209
|
auth,
|
|
6210
|
+
clientKey: forwardedClientKey(req),
|
|
4505
6211
|
deploy: loaded.deploy,
|
|
4506
6212
|
queries: new Set()
|
|
4507
6213
|
});
|
|
@@ -4522,6 +6228,7 @@ export async function startAnonymousServer({
|
|
|
4522
6228
|
}
|
|
4523
6229
|
|
|
4524
6230
|
try {
|
|
6231
|
+
await enforceClientTrafficQuota(subscription.clientKey, "requests");
|
|
4525
6232
|
await resolvedStore.incrementQuota(
|
|
4526
6233
|
subscription.deploy.id,
|
|
4527
6234
|
"requests",
|
|
@@ -4548,6 +6255,7 @@ export async function startAnonymousServer({
|
|
|
4548
6255
|
}
|
|
4549
6256
|
|
|
4550
6257
|
if (message.op === "mutation.run") {
|
|
6258
|
+
await enforceClientTrafficQuota(subscription.clientKey, "mutations");
|
|
4551
6259
|
await resolvedStore.incrementQuota(
|
|
4552
6260
|
subscription.deploy.id,
|
|
4553
6261
|
"mutations",
|
|
@@ -4574,7 +6282,14 @@ export async function startAnonymousServer({
|
|
|
4574
6282
|
error: error instanceof Error ? error.message : String(error),
|
|
4575
6283
|
op: message?.op
|
|
4576
6284
|
});
|
|
6285
|
+
const quota = isQuotaError(error) ? quotaErrorBody(error) : null;
|
|
4577
6286
|
websocketSend(ws, {
|
|
6287
|
+
...(quota
|
|
6288
|
+
? {
|
|
6289
|
+
code: quota.code,
|
|
6290
|
+
quota
|
|
6291
|
+
}
|
|
6292
|
+
: {}),
|
|
4578
6293
|
error: error instanceof Error ? error.message : String(error),
|
|
4579
6294
|
id: message?.id,
|
|
4580
6295
|
ok: false,
|
|
@@ -4621,6 +6336,20 @@ export async function startAnonymousServer({
|
|
|
4621
6336
|
});
|
|
4622
6337
|
});
|
|
4623
6338
|
|
|
6339
|
+
void runCleanup("startup").catch((error) => {
|
|
6340
|
+
if (!quiet) {
|
|
6341
|
+
console.error(`[lakebed:cleanup] startup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6342
|
+
}
|
|
6343
|
+
});
|
|
6344
|
+
cleanupInterval = setInterval(() => {
|
|
6345
|
+
void runCleanup("periodic").catch((error) => {
|
|
6346
|
+
if (!quiet) {
|
|
6347
|
+
console.error(`[lakebed:cleanup] periodic failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6348
|
+
}
|
|
6349
|
+
});
|
|
6350
|
+
}, cleanupPolicy.intervalSeconds * 1000);
|
|
6351
|
+
cleanupInterval.unref?.();
|
|
6352
|
+
|
|
4624
6353
|
if (!quiet) {
|
|
4625
6354
|
console.log(`Lakebed anonymous runner listening at ${resolvedPublicRootUrl}`);
|
|
4626
6355
|
}
|
|
@@ -4632,6 +6361,9 @@ export async function startAnonymousServer({
|
|
|
4632
6361
|
store: resolvedStore,
|
|
4633
6362
|
url: resolvedPublicRootUrl,
|
|
4634
6363
|
async close() {
|
|
6364
|
+
if (cleanupInterval) {
|
|
6365
|
+
clearInterval(cleanupInterval);
|
|
6366
|
+
}
|
|
4635
6367
|
for (const client of wss.clients) {
|
|
4636
6368
|
client.close();
|
|
4637
6369
|
}
|