@wopr-network/platform-core 1.69.0 → 1.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/schema/pool-config.d.ts +41 -0
- package/dist/db/schema/pool-config.js +5 -0
- package/dist/db/schema/pool-instances.d.ts +126 -0
- package/dist/db/schema/pool-instances.js +10 -0
- package/dist/server/__tests__/container.test.js +4 -1
- package/dist/server/container.d.ts +20 -2
- package/dist/server/container.js +20 -5
- package/dist/server/lifecycle.js +10 -0
- package/dist/server/routes/admin.d.ts +18 -0
- package/dist/server/routes/admin.js +21 -0
- package/dist/server/services/hot-pool-claim.d.ts +30 -0
- package/dist/server/services/hot-pool-claim.js +92 -0
- package/dist/server/services/hot-pool.d.ts +25 -0
- package/dist/server/services/hot-pool.js +129 -0
- package/dist/server/services/pool-repository.d.ts +44 -0
- package/dist/server/services/pool-repository.js +72 -0
- package/dist/server/test-container.js +14 -0
- package/dist/trpc/index.d.ts +4 -1
- package/dist/trpc/index.js +4 -1
- package/dist/trpc/init.d.ts +5 -0
- package/dist/trpc/init.js +28 -0
- package/dist/trpc/routers/page-context.d.ts +37 -0
- package/dist/trpc/routers/page-context.js +41 -0
- package/dist/trpc/routers/profile.d.ts +71 -0
- package/dist/trpc/routers/profile.js +68 -0
- package/dist/trpc/routers/settings.d.ts +85 -0
- package/dist/trpc/routers/settings.js +73 -0
- package/drizzle/migrations/0025_hot_pool_tables.sql +29 -0
- package/package.json +1 -1
- package/src/db/schema/pool-config.ts +6 -0
- package/src/db/schema/pool-instances.ts +11 -0
- package/src/server/__tests__/container.test.ts +4 -1
- package/src/server/container.ts +38 -8
- package/src/server/lifecycle.ts +10 -0
- package/src/server/routes/admin.ts +26 -0
- package/src/server/services/hot-pool-claim.ts +130 -0
- package/src/server/services/hot-pool.ts +174 -0
- package/src/server/services/pool-repository.ts +107 -0
- package/src/server/test-container.ts +15 -0
- package/src/trpc/index.ts +4 -0
- package/src/trpc/init.ts +28 -0
- package/src/trpc/routers/page-context.ts +57 -0
- package/src/trpc/routers/profile.ts +94 -0
- package/src/trpc/routers/settings.ts +97 -0
|
@@ -172,7 +172,10 @@ describe("createTestContainer", () => {
|
|
|
172
172
|
};
|
|
173
173
|
|
|
174
174
|
const hotPool: HotPoolServices = {
|
|
175
|
-
|
|
175
|
+
start: async () => ({ stop: () => {} }),
|
|
176
|
+
claim: async () => null,
|
|
177
|
+
getPoolSize: async () => 2,
|
|
178
|
+
setPoolSize: async () => {},
|
|
176
179
|
};
|
|
177
180
|
|
|
178
181
|
const c = createTestContainer({ fleet, crypto, stripe, gateway, hotPool });
|
package/src/server/container.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { FleetManager } from "../fleet/fleet-manager.js";
|
|
|
19
19
|
import type { IProfileStore } from "../fleet/profile-store.js";
|
|
20
20
|
import type { IServiceKeyRepository } from "../gateway/service-key-repository.js";
|
|
21
21
|
import type { ProductConfig } from "../product-config/repository-types.js";
|
|
22
|
+
import type { ProductConfigService } from "../product-config/service.js";
|
|
22
23
|
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
23
24
|
import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
24
25
|
import type { OrgService } from "../tenancy/org-service.js";
|
|
@@ -53,8 +54,18 @@ export interface GatewayServices {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface HotPoolServices {
|
|
56
|
-
/**
|
|
57
|
-
|
|
57
|
+
/** Start the pool manager (replenish loop + cleanup). */
|
|
58
|
+
start: () => Promise<{ stop: () => void }>;
|
|
59
|
+
/** Claim a warm instance from the pool. Returns null if empty. */
|
|
60
|
+
claim: (
|
|
61
|
+
name: string,
|
|
62
|
+
tenantId: string,
|
|
63
|
+
adminUser: { id: string; email: string; name: string },
|
|
64
|
+
) => Promise<{ id: string; name: string; subdomain: string } | null>;
|
|
65
|
+
/** Get current pool size from DB. */
|
|
66
|
+
getPoolSize: () => Promise<number>;
|
|
67
|
+
/** Set pool size in DB. */
|
|
68
|
+
setPoolSize: (size: number) => Promise<void>;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
// ---------------------------------------------------------------------------
|
|
@@ -65,6 +76,7 @@ export interface PlatformContainer {
|
|
|
65
76
|
db: DrizzleDb;
|
|
66
77
|
pool: Pool;
|
|
67
78
|
productConfig: ProductConfig;
|
|
79
|
+
productConfigService: ProductConfigService;
|
|
68
80
|
creditLedger: ILedger;
|
|
69
81
|
orgMemberRepo: IOrgMemberRepository;
|
|
70
82
|
orgService: OrgService;
|
|
@@ -118,7 +130,7 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
|
|
|
118
130
|
|
|
119
131
|
// 4. Bootstrap product config from DB (auto-seeds from presets if needed)
|
|
120
132
|
const { platformBoot } = await import("../product-config/boot.js");
|
|
121
|
-
const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
|
|
133
|
+
const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
|
|
122
134
|
|
|
123
135
|
// 5. Credit ledger
|
|
124
136
|
const { DrizzleLedger } = await import("../credits/ledger.js");
|
|
@@ -217,13 +229,12 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
|
|
|
217
229
|
gateway = { serviceKeyRepo };
|
|
218
230
|
}
|
|
219
231
|
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
return {
|
|
232
|
+
// 12. Build the container (hotPool bound after construction)
|
|
233
|
+
const result: PlatformContainer = {
|
|
224
234
|
db,
|
|
225
235
|
pool,
|
|
226
236
|
productConfig,
|
|
237
|
+
productConfigService,
|
|
227
238
|
creditLedger,
|
|
228
239
|
orgMemberRepo,
|
|
229
240
|
orgService,
|
|
@@ -232,6 +243,25 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
|
|
|
232
243
|
crypto,
|
|
233
244
|
stripe,
|
|
234
245
|
gateway,
|
|
235
|
-
hotPool,
|
|
246
|
+
hotPool: null,
|
|
236
247
|
};
|
|
248
|
+
|
|
249
|
+
// Bind hot pool after container construction (closures need the full container)
|
|
250
|
+
if (bootConfig.features.hotPool && fleet) {
|
|
251
|
+
const { startHotPool, setPoolSize: setSize, getPoolSize: getSize } = await import("./services/hot-pool.js");
|
|
252
|
+
const { claimPoolInstance } = await import("./services/hot-pool-claim.js");
|
|
253
|
+
const { DrizzlePoolRepository } = await import("./services/pool-repository.js");
|
|
254
|
+
const poolRepo = new DrizzlePoolRepository(pool);
|
|
255
|
+
|
|
256
|
+
const hotPoolConfig = { provisionSecret: bootConfig.provisionSecret };
|
|
257
|
+
result.hotPool = {
|
|
258
|
+
start: () => startHotPool(result, poolRepo, hotPoolConfig),
|
|
259
|
+
claim: (name, tenantId, adminUser) =>
|
|
260
|
+
claimPoolInstance(result, poolRepo, name, tenantId, adminUser, hotPoolConfig),
|
|
261
|
+
getPoolSize: () => getSize(poolRepo),
|
|
262
|
+
setPoolSize: (size) => setSize(poolRepo, size),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return result;
|
|
237
267
|
}
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -40,6 +40,16 @@ export async function startBackgroundServices(container: PlatformContainer): Pro
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Hot pool manager (if enabled)
|
|
44
|
+
if (container.hotPool) {
|
|
45
|
+
try {
|
|
46
|
+
const poolHandles = await container.hotPool.start();
|
|
47
|
+
handles.unsubscribes.push(poolHandles.stop);
|
|
48
|
+
} catch {
|
|
49
|
+
// Non-fatal — pool will be empty but claiming falls back to cold create
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
return handles;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -330,5 +330,31 @@ export function createAdminRouter(container: PlatformContainer, config?: AdminRo
|
|
|
330
330
|
orgCount,
|
|
331
331
|
};
|
|
332
332
|
}),
|
|
333
|
+
|
|
334
|
+
// -----------------------------------------------------------------------
|
|
335
|
+
// Hot pool config (DB-driven, admin-only)
|
|
336
|
+
// -----------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
getPoolConfig: adminProcedure.query(async () => {
|
|
339
|
+
if (!container.hotPool) {
|
|
340
|
+
return { enabled: false, poolSize: 0, warmCount: 0 };
|
|
341
|
+
}
|
|
342
|
+
const poolSize = await container.hotPool.getPoolSize();
|
|
343
|
+
const warmRes = await container.pool.query<{ count: string }>(
|
|
344
|
+
"SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'",
|
|
345
|
+
);
|
|
346
|
+
const warmCount = Number(warmRes.rows[0]?.count ?? 0);
|
|
347
|
+
return { enabled: true, poolSize, warmCount };
|
|
348
|
+
}),
|
|
349
|
+
|
|
350
|
+
setPoolSize: adminProcedure
|
|
351
|
+
.input(z.object({ size: z.number().int().min(0).max(50) }))
|
|
352
|
+
.mutation(async ({ input }) => {
|
|
353
|
+
if (!container.hotPool) {
|
|
354
|
+
throw new Error("Hot pool not enabled");
|
|
355
|
+
}
|
|
356
|
+
await container.hotPool.setPoolSize(input.size);
|
|
357
|
+
return { poolSize: input.size };
|
|
358
|
+
}),
|
|
333
359
|
});
|
|
334
360
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic hot pool claiming.
|
|
3
|
+
*
|
|
4
|
+
* Uses IPoolRepository for all DB operations. No raw pool.query().
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
import { logger } from "../../config/logger.js";
|
|
10
|
+
import type { PlatformContainer } from "../container.js";
|
|
11
|
+
import { replenishPool } from "./hot-pool.js";
|
|
12
|
+
import type { IPoolRepository } from "./pool-repository.js";
|
|
13
|
+
|
|
14
|
+
export interface ClaimConfig {
|
|
15
|
+
/** Shared secret for provision auth. */
|
|
16
|
+
provisionSecret: string;
|
|
17
|
+
/** Container name prefix. Default: "wopr". */
|
|
18
|
+
containerPrefix?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClaimAdminUser {
|
|
22
|
+
id: string;
|
|
23
|
+
email: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ClaimResult {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
subdomain: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Claim a warm pool instance, rename it, create a fleet profile,
|
|
35
|
+
* and register the proxy route.
|
|
36
|
+
*
|
|
37
|
+
* Returns the claim result on success, or null if the pool is empty.
|
|
38
|
+
*/
|
|
39
|
+
export async function claimPoolInstance(
|
|
40
|
+
container: PlatformContainer,
|
|
41
|
+
repo: IPoolRepository,
|
|
42
|
+
name: string,
|
|
43
|
+
tenantId: string,
|
|
44
|
+
_adminUser: ClaimAdminUser,
|
|
45
|
+
config?: ClaimConfig,
|
|
46
|
+
): Promise<ClaimResult | null> {
|
|
47
|
+
if (!container.fleet) throw new Error("Fleet services required for pool claim");
|
|
48
|
+
|
|
49
|
+
const pc = container.productConfig;
|
|
50
|
+
const containerPort = pc.fleet?.containerPort ?? 3100;
|
|
51
|
+
const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
|
|
52
|
+
const platformDomain = pc.product?.domain ?? "localhost";
|
|
53
|
+
const prefix = config?.containerPrefix ?? "wopr";
|
|
54
|
+
|
|
55
|
+
// ---- Step 1: Atomically claim a warm instance ----
|
|
56
|
+
const claimed = await repo.claimWarm(tenantId, name);
|
|
57
|
+
if (!claimed) return null;
|
|
58
|
+
|
|
59
|
+
const { id: instanceId, containerId } = claimed;
|
|
60
|
+
|
|
61
|
+
// ---- Step 2: Rename Docker container ----
|
|
62
|
+
const docker = container.fleet.docker;
|
|
63
|
+
const containerName = `${prefix}-${name}`;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const c = docker.getContainer(containerId);
|
|
67
|
+
await c.rename({ name: containerName });
|
|
68
|
+
logger.info(`Pool claim: renamed container to ${containerName}`);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger.error("Pool claim: rename failed", { error: (err as Error).message });
|
|
71
|
+
await repo.markDead(instanceId);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- Step 3: Create fleet profile ----
|
|
76
|
+
const serviceKeyRepo = container.fleet.serviceKeyRepo;
|
|
77
|
+
const gatewayKey = serviceKeyRepo ? await serviceKeyRepo.generate(tenantId, instanceId) : crypto.randomUUID();
|
|
78
|
+
|
|
79
|
+
const store = container.fleet.profileStore;
|
|
80
|
+
const profile = {
|
|
81
|
+
id: instanceId,
|
|
82
|
+
name,
|
|
83
|
+
tenantId,
|
|
84
|
+
image: containerImage,
|
|
85
|
+
description: `Managed instance: ${name}`,
|
|
86
|
+
env: {
|
|
87
|
+
PORT: String(containerPort),
|
|
88
|
+
HOST: "0.0.0.0",
|
|
89
|
+
NODE_ENV: "production",
|
|
90
|
+
PROVISION_SECRET: config?.provisionSecret ?? "",
|
|
91
|
+
BETTER_AUTH_SECRET: randomBytes(32).toString("hex"),
|
|
92
|
+
DATA_HOME: "/data",
|
|
93
|
+
HOSTED_MODE: "true",
|
|
94
|
+
DEPLOYMENT_MODE: "hosted_proxy",
|
|
95
|
+
DEPLOYMENT_EXPOSURE: "private",
|
|
96
|
+
MIGRATION_AUTO_APPLY: "true",
|
|
97
|
+
GATEWAY_KEY: gatewayKey,
|
|
98
|
+
},
|
|
99
|
+
restartPolicy: "unless-stopped" as const,
|
|
100
|
+
releaseChannel: "stable" as const,
|
|
101
|
+
updatePolicy: "manual" as const,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await store.save(profile);
|
|
105
|
+
logger.info(`Pool claim: saved fleet profile for ${name} (${instanceId})`);
|
|
106
|
+
|
|
107
|
+
// ---- Step 4: Register proxy route ----
|
|
108
|
+
try {
|
|
109
|
+
if (container.fleet.proxy.addRoute) {
|
|
110
|
+
await container.fleet.proxy.addRoute({
|
|
111
|
+
instanceId,
|
|
112
|
+
subdomain: name,
|
|
113
|
+
upstreamHost: containerName,
|
|
114
|
+
upstreamPort: containerPort,
|
|
115
|
+
healthy: true,
|
|
116
|
+
});
|
|
117
|
+
logger.info(`Pool claim: registered proxy route ${name}.${platformDomain}`);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logger.error("Pool claim: proxy route registration failed", { error: (err as Error).message });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Step 5: Replenish pool in background ----
|
|
124
|
+
replenishPool(container, repo, { provisionSecret: config?.provisionSecret ?? "" }).catch((err) => {
|
|
125
|
+
logger.error("Pool replenish after claim failed", { error: (err as Error).message });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const subdomain = `${name}.${platformDomain}`;
|
|
129
|
+
return { id: instanceId, name, subdomain };
|
|
130
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot pool manager — pre-provisions warm containers for instant claiming.
|
|
3
|
+
*
|
|
4
|
+
* Reads desired pool size from DB (`pool_config` table) via IPoolRepository.
|
|
5
|
+
* Periodically replenishes the pool and cleans up dead containers.
|
|
6
|
+
*
|
|
7
|
+
* All config is DB-driven — no env vars for pool size, container image,
|
|
8
|
+
* or port. Admin API updates pool_config, this reads it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from "../../config/logger.js";
|
|
12
|
+
import type { PlatformContainer } from "../container.js";
|
|
13
|
+
import type { IPoolRepository } from "./pool-repository.js";
|
|
14
|
+
|
|
15
|
+
export interface HotPoolConfig {
|
|
16
|
+
/** Shared secret for provision auth between platform and managed instances. */
|
|
17
|
+
provisionSecret: string;
|
|
18
|
+
/** Replenish interval in ms. Default: 60_000. */
|
|
19
|
+
replenishIntervalMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HotPoolHandles {
|
|
23
|
+
replenishTimer: ReturnType<typeof setInterval>;
|
|
24
|
+
stop: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Pool size — delegates to repository
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export async function getPoolSize(repo: IPoolRepository): Promise<number> {
|
|
32
|
+
return repo.getPoolSize();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function setPoolSize(repo: IPoolRepository, size: number): Promise<void> {
|
|
36
|
+
return repo.setPoolSize(size);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Warm container management
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function createWarmContainer(
|
|
44
|
+
container: PlatformContainer,
|
|
45
|
+
repo: IPoolRepository,
|
|
46
|
+
config: HotPoolConfig,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (!container.fleet) throw new Error("Fleet services required for hot pool");
|
|
49
|
+
|
|
50
|
+
const pc = container.productConfig;
|
|
51
|
+
const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
|
|
52
|
+
const containerPort = pc.fleet?.containerPort ?? 3100;
|
|
53
|
+
const provisionSecret = config.provisionSecret;
|
|
54
|
+
const dockerNetwork = pc.fleet?.dockerNetwork ?? "";
|
|
55
|
+
const docker = container.fleet.docker;
|
|
56
|
+
const id = crypto.randomUUID();
|
|
57
|
+
const containerName = `pool-${id.slice(0, 8)}`;
|
|
58
|
+
const volumeName = `pool-${id.slice(0, 8)}`;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Init volume permissions
|
|
62
|
+
const init = await docker.createContainer({
|
|
63
|
+
Image: containerImage,
|
|
64
|
+
Entrypoint: ["/bin/sh", "-c"],
|
|
65
|
+
Cmd: ["chown -R 999:999 /data"],
|
|
66
|
+
User: "root",
|
|
67
|
+
HostConfig: { Binds: [`${volumeName}:/data`] },
|
|
68
|
+
});
|
|
69
|
+
await init.start();
|
|
70
|
+
await init.wait();
|
|
71
|
+
await init.remove();
|
|
72
|
+
|
|
73
|
+
const warmContainer = await docker.createContainer({
|
|
74
|
+
Image: containerImage,
|
|
75
|
+
name: containerName,
|
|
76
|
+
Env: [`PORT=${containerPort}`, `PROVISION_SECRET=${provisionSecret}`, "HOME=/data"],
|
|
77
|
+
HostConfig: {
|
|
78
|
+
Binds: [`${volumeName}:/data`],
|
|
79
|
+
RestartPolicy: { Name: "unless-stopped" },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await warmContainer.start();
|
|
84
|
+
|
|
85
|
+
if (dockerNetwork) {
|
|
86
|
+
const network = docker.getNetwork(dockerNetwork);
|
|
87
|
+
await network.connect({ Container: warmContainer.id });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await repo.insertWarm(id, warmContainer.id);
|
|
91
|
+
|
|
92
|
+
logger.info(`Hot pool: created warm container ${containerName} (${id})`);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
logger.error("Hot pool: failed to create warm container", {
|
|
95
|
+
error: (err as Error).message,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function replenishPool(
|
|
101
|
+
container: PlatformContainer,
|
|
102
|
+
repo: IPoolRepository,
|
|
103
|
+
config: HotPoolConfig,
|
|
104
|
+
): Promise<void> {
|
|
105
|
+
const desired = await repo.getPoolSize();
|
|
106
|
+
const current = await repo.warmCount();
|
|
107
|
+
const deficit = desired - current;
|
|
108
|
+
|
|
109
|
+
if (deficit <= 0) return;
|
|
110
|
+
|
|
111
|
+
logger.info(`Hot pool: replenishing ${deficit} container(s) (have ${current}, want ${desired})`);
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < deficit; i++) {
|
|
114
|
+
await createWarmContainer(container, repo, config);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cleanupDead(container: PlatformContainer, repo: IPoolRepository): Promise<void> {
|
|
119
|
+
if (!container.fleet) return;
|
|
120
|
+
|
|
121
|
+
const docker = container.fleet.docker;
|
|
122
|
+
const warmInstances = await repo.listWarm();
|
|
123
|
+
|
|
124
|
+
for (const instance of warmInstances) {
|
|
125
|
+
try {
|
|
126
|
+
const c = docker.getContainer(instance.containerId);
|
|
127
|
+
const info = await c.inspect();
|
|
128
|
+
if (!info.State.Running) {
|
|
129
|
+
await repo.markDead(instance.id);
|
|
130
|
+
try {
|
|
131
|
+
await c.remove({ force: true });
|
|
132
|
+
} catch {
|
|
133
|
+
/* already gone */
|
|
134
|
+
}
|
|
135
|
+
logger.warn(`Hot pool: marked dead container ${instance.id}`);
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
await repo.markDead(instance.id);
|
|
139
|
+
logger.warn(`Hot pool: marked missing container ${instance.id} as dead`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await repo.deleteDead();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Lifecycle
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export async function startHotPool(
|
|
151
|
+
container: PlatformContainer,
|
|
152
|
+
repo: IPoolRepository,
|
|
153
|
+
config: HotPoolConfig,
|
|
154
|
+
): Promise<HotPoolHandles> {
|
|
155
|
+
await cleanupDead(container, repo);
|
|
156
|
+
await replenishPool(container, repo, config);
|
|
157
|
+
|
|
158
|
+
const intervalMs = config.replenishIntervalMs ?? 60_000;
|
|
159
|
+
const replenishTimer = setInterval(async () => {
|
|
160
|
+
try {
|
|
161
|
+
await cleanupDead(container, repo);
|
|
162
|
+
await replenishPool(container, repo, config);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
logger.error("Hot pool tick failed", { error: (err as Error).message });
|
|
165
|
+
}
|
|
166
|
+
}, intervalMs);
|
|
167
|
+
|
|
168
|
+
logger.info("Hot pool manager started");
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
replenishTimer,
|
|
172
|
+
stop: () => clearInterval(replenishTimer),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository for hot pool database operations.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates all pool_config and pool_instances queries behind
|
|
5
|
+
* a testable interface. No raw pool.query() outside this file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Pool } from "pg";
|
|
9
|
+
|
|
10
|
+
export interface PoolInstance {
|
|
11
|
+
id: string;
|
|
12
|
+
containerId: string;
|
|
13
|
+
status: string;
|
|
14
|
+
tenantId: string | null;
|
|
15
|
+
name: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IPoolRepository {
|
|
19
|
+
getPoolSize(): Promise<number>;
|
|
20
|
+
setPoolSize(size: number): Promise<void>;
|
|
21
|
+
warmCount(): Promise<number>;
|
|
22
|
+
insertWarm(id: string, containerId: string): Promise<void>;
|
|
23
|
+
listWarm(): Promise<PoolInstance[]>;
|
|
24
|
+
markDead(id: string): Promise<void>;
|
|
25
|
+
deleteDead(): Promise<void>;
|
|
26
|
+
claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null>;
|
|
27
|
+
updateInstanceStatus(id: string, status: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class DrizzlePoolRepository implements IPoolRepository {
|
|
31
|
+
constructor(private pool: Pool) {}
|
|
32
|
+
|
|
33
|
+
async getPoolSize(): Promise<number> {
|
|
34
|
+
try {
|
|
35
|
+
const res = await this.pool.query("SELECT pool_size FROM pool_config WHERE id = 1");
|
|
36
|
+
return res.rows[0]?.pool_size ?? 2;
|
|
37
|
+
} catch {
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async setPoolSize(size: number): Promise<void> {
|
|
43
|
+
await this.pool.query(
|
|
44
|
+
"INSERT INTO pool_config (id, pool_size) VALUES (1, $1) ON CONFLICT (id) DO UPDATE SET pool_size = $1",
|
|
45
|
+
[size],
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async warmCount(): Promise<number> {
|
|
50
|
+
const res = await this.pool.query("SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'");
|
|
51
|
+
return res.rows[0].count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async insertWarm(id: string, containerId: string): Promise<void> {
|
|
55
|
+
await this.pool.query("INSERT INTO pool_instances (id, container_id, status) VALUES ($1, $2, 'warm')", [
|
|
56
|
+
id,
|
|
57
|
+
containerId,
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async listWarm(): Promise<PoolInstance[]> {
|
|
62
|
+
const res = await this.pool.query(
|
|
63
|
+
"SELECT id, container_id, status, tenant_id, name FROM pool_instances WHERE status = 'warm'",
|
|
64
|
+
);
|
|
65
|
+
return res.rows.map((r: Record<string, unknown>) => ({
|
|
66
|
+
id: r.id as string,
|
|
67
|
+
containerId: r.container_id as string,
|
|
68
|
+
status: r.status as string,
|
|
69
|
+
tenantId: (r.tenant_id as string) ?? null,
|
|
70
|
+
name: (r.name as string) ?? null,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async markDead(id: string): Promise<void> {
|
|
75
|
+
await this.pool.query("UPDATE pool_instances SET status = 'dead' WHERE id = $1", [id]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteDead(): Promise<void> {
|
|
79
|
+
await this.pool.query("DELETE FROM pool_instances WHERE status = 'dead'");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async claimWarm(tenantId: string, name: string): Promise<{ id: string; containerId: string } | null> {
|
|
83
|
+
const res = await this.pool.query(
|
|
84
|
+
`UPDATE pool_instances
|
|
85
|
+
SET status = 'claimed',
|
|
86
|
+
claimed_at = NOW(),
|
|
87
|
+
tenant_id = $1,
|
|
88
|
+
name = $2
|
|
89
|
+
WHERE id = (
|
|
90
|
+
SELECT id FROM pool_instances
|
|
91
|
+
WHERE status = 'warm'
|
|
92
|
+
ORDER BY created_at ASC
|
|
93
|
+
LIMIT 1
|
|
94
|
+
FOR UPDATE SKIP LOCKED
|
|
95
|
+
)
|
|
96
|
+
RETURNING id, container_id`,
|
|
97
|
+
[tenantId, name],
|
|
98
|
+
);
|
|
99
|
+
if (res.rowCount === 0) return null;
|
|
100
|
+
const row = res.rows[0] as { id: string; container_id: string };
|
|
101
|
+
return { id: row.id, containerId: row.container_id };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async updateInstanceStatus(id: string, status: string): Promise<void> {
|
|
105
|
+
await this.pool.query("UPDATE pool_instances SET status = $1 WHERE id = $2", [status, id]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -12,6 +12,7 @@ import type { IUserRoleRepository } from "../auth/user-role-repository.js";
|
|
|
12
12
|
import type { ILedger } from "../credits/ledger.js";
|
|
13
13
|
import type { DrizzleDb } from "../db/index.js";
|
|
14
14
|
import type { ProductConfig } from "../product-config/repository-types.js";
|
|
15
|
+
import { ProductConfigService } from "../product-config/service.js";
|
|
15
16
|
import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
16
17
|
import type { OrgService } from "../tenancy/org-service.js";
|
|
17
18
|
import type { PlatformContainer } from "./container.js";
|
|
@@ -90,6 +91,19 @@ function stubProductConfig(): ProductConfig {
|
|
|
90
91
|
};
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
function stubProductConfigService(): ProductConfigService {
|
|
95
|
+
const stubRepo = {
|
|
96
|
+
getBySlug: async () => stubProductConfig(),
|
|
97
|
+
listAll: async () => [],
|
|
98
|
+
upsertProduct: async () => stubProductConfig().product,
|
|
99
|
+
replaceNavItems: async () => {},
|
|
100
|
+
upsertFeatures: async () => {},
|
|
101
|
+
upsertFleetConfig: async () => {},
|
|
102
|
+
upsertBillingConfig: async () => {},
|
|
103
|
+
};
|
|
104
|
+
return new ProductConfigService(stubRepo as never);
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
// ---------------------------------------------------------------------------
|
|
94
108
|
// Public API
|
|
95
109
|
// ---------------------------------------------------------------------------
|
|
@@ -103,6 +117,7 @@ export function createTestContainer(overrides?: Partial<PlatformContainer>): Pla
|
|
|
103
117
|
db: {} as DrizzleDb,
|
|
104
118
|
pool: { end: async () => {} } as never,
|
|
105
119
|
productConfig: stubProductConfig(),
|
|
120
|
+
productConfigService: stubProductConfigService(),
|
|
106
121
|
creditLedger: stubLedger(),
|
|
107
122
|
orgMemberRepo: stubOrgMemberRepo(),
|
|
108
123
|
orgService: {} as OrgService,
|
package/src/trpc/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
|
|
|
13
13
|
export {
|
|
14
14
|
adminProcedure,
|
|
15
15
|
createCallerFactory,
|
|
16
|
+
createTRPCContext,
|
|
16
17
|
orgAdminProcedure,
|
|
17
18
|
orgMemberProcedure,
|
|
18
19
|
protectedProcedure,
|
|
@@ -28,3 +29,6 @@ export {
|
|
|
28
29
|
type OrgRemovePaymentMethodDeps,
|
|
29
30
|
} from "./org-remove-payment-method-router.js";
|
|
30
31
|
export { createProductConfigRouter } from "./product-config-router.js";
|
|
32
|
+
export { type PageContextRouterDeps, pageContextRouter, setPageContextRouterDeps } from "./routers/page-context.js";
|
|
33
|
+
export { type ProfileRouterDeps, profileRouter, setProfileRouterDeps } from "./routers/profile.js";
|
|
34
|
+
export { type SettingsRouterDeps, setSettingsRouterDeps, settingsRouter } from "./routers/settings.js";
|
package/src/trpc/init.ts
CHANGED
|
@@ -22,6 +22,34 @@ export interface TRPCContext {
|
|
|
22
22
|
tenantId: string | undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Context factory — resolves BetterAuth session into TRPCContext
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a TRPCContext from an incoming request.
|
|
31
|
+
* Resolves the user from BetterAuth session cookies.
|
|
32
|
+
*/
|
|
33
|
+
export async function createTRPCContext(req: Request): Promise<TRPCContext> {
|
|
34
|
+
let user: AuthUser | undefined;
|
|
35
|
+
let tenantId: string | undefined;
|
|
36
|
+
try {
|
|
37
|
+
const { getAuth } = await import("../auth/better-auth.js");
|
|
38
|
+
const auth = getAuth();
|
|
39
|
+
const session = await auth.api.getSession({ headers: req.headers });
|
|
40
|
+
if (session?.user) {
|
|
41
|
+
const sessionUser = session.user as { id: string; role?: string };
|
|
42
|
+
const roles: string[] = [];
|
|
43
|
+
if (sessionUser.role) roles.push(sessionUser.role);
|
|
44
|
+
user = { id: sessionUser.id, roles };
|
|
45
|
+
tenantId = req.headers.get("x-tenant-id") || sessionUser.id;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// No session — unauthenticated request
|
|
49
|
+
}
|
|
50
|
+
return { user, tenantId: tenantId ?? "" };
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
// ---------------------------------------------------------------------------
|
|
26
54
|
// tRPC init
|
|
27
55
|
// ---------------------------------------------------------------------------
|