@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const poolConfig: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2
|
+
name: "pool_config";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "pool_config";
|
|
8
|
+
dataType: "number";
|
|
9
|
+
columnType: "PgInteger";
|
|
10
|
+
data: number;
|
|
11
|
+
driverParam: string | number;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: true;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: undefined;
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
poolSize: import("drizzle-orm/pg-core").PgColumn<{
|
|
23
|
+
name: "pool_size";
|
|
24
|
+
tableName: "pool_config";
|
|
25
|
+
dataType: "number";
|
|
26
|
+
columnType: "PgInteger";
|
|
27
|
+
data: number;
|
|
28
|
+
driverParam: string | number;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: true;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: undefined;
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
};
|
|
40
|
+
dialect: "pg";
|
|
41
|
+
}>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export declare const poolInstances: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2
|
+
name: "pool_instances";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "pool_instances";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "PgText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: [string, ...string[]];
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
containerId: import("drizzle-orm/pg-core").PgColumn<{
|
|
23
|
+
name: "container_id";
|
|
24
|
+
tableName: "pool_instances";
|
|
25
|
+
dataType: "string";
|
|
26
|
+
columnType: "PgText";
|
|
27
|
+
data: string;
|
|
28
|
+
driverParam: string;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: false;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: [string, ...string[]];
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
status: import("drizzle-orm/pg-core").PgColumn<{
|
|
40
|
+
name: "status";
|
|
41
|
+
tableName: "pool_instances";
|
|
42
|
+
dataType: "string";
|
|
43
|
+
columnType: "PgText";
|
|
44
|
+
data: string;
|
|
45
|
+
driverParam: string;
|
|
46
|
+
notNull: true;
|
|
47
|
+
hasDefault: true;
|
|
48
|
+
isPrimaryKey: false;
|
|
49
|
+
isAutoincrement: false;
|
|
50
|
+
hasRuntimeDefault: false;
|
|
51
|
+
enumValues: [string, ...string[]];
|
|
52
|
+
baseColumn: never;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
}, {}, {}>;
|
|
56
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
57
|
+
name: "tenant_id";
|
|
58
|
+
tableName: "pool_instances";
|
|
59
|
+
dataType: "string";
|
|
60
|
+
columnType: "PgText";
|
|
61
|
+
data: string;
|
|
62
|
+
driverParam: string;
|
|
63
|
+
notNull: false;
|
|
64
|
+
hasDefault: false;
|
|
65
|
+
isPrimaryKey: false;
|
|
66
|
+
isAutoincrement: false;
|
|
67
|
+
hasRuntimeDefault: false;
|
|
68
|
+
enumValues: [string, ...string[]];
|
|
69
|
+
baseColumn: never;
|
|
70
|
+
identity: undefined;
|
|
71
|
+
generated: undefined;
|
|
72
|
+
}, {}, {}>;
|
|
73
|
+
name: import("drizzle-orm/pg-core").PgColumn<{
|
|
74
|
+
name: "name";
|
|
75
|
+
tableName: "pool_instances";
|
|
76
|
+
dataType: "string";
|
|
77
|
+
columnType: "PgText";
|
|
78
|
+
data: string;
|
|
79
|
+
driverParam: string;
|
|
80
|
+
notNull: false;
|
|
81
|
+
hasDefault: false;
|
|
82
|
+
isPrimaryKey: false;
|
|
83
|
+
isAutoincrement: false;
|
|
84
|
+
hasRuntimeDefault: false;
|
|
85
|
+
enumValues: [string, ...string[]];
|
|
86
|
+
baseColumn: never;
|
|
87
|
+
identity: undefined;
|
|
88
|
+
generated: undefined;
|
|
89
|
+
}, {}, {}>;
|
|
90
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
91
|
+
name: "created_at";
|
|
92
|
+
tableName: "pool_instances";
|
|
93
|
+
dataType: "date";
|
|
94
|
+
columnType: "PgTimestamp";
|
|
95
|
+
data: Date;
|
|
96
|
+
driverParam: string;
|
|
97
|
+
notNull: true;
|
|
98
|
+
hasDefault: true;
|
|
99
|
+
isPrimaryKey: false;
|
|
100
|
+
isAutoincrement: false;
|
|
101
|
+
hasRuntimeDefault: false;
|
|
102
|
+
enumValues: undefined;
|
|
103
|
+
baseColumn: never;
|
|
104
|
+
identity: undefined;
|
|
105
|
+
generated: undefined;
|
|
106
|
+
}, {}, {}>;
|
|
107
|
+
claimedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
108
|
+
name: "claimed_at";
|
|
109
|
+
tableName: "pool_instances";
|
|
110
|
+
dataType: "date";
|
|
111
|
+
columnType: "PgTimestamp";
|
|
112
|
+
data: Date;
|
|
113
|
+
driverParam: string;
|
|
114
|
+
notNull: false;
|
|
115
|
+
hasDefault: false;
|
|
116
|
+
isPrimaryKey: false;
|
|
117
|
+
isAutoincrement: false;
|
|
118
|
+
hasRuntimeDefault: false;
|
|
119
|
+
enumValues: undefined;
|
|
120
|
+
baseColumn: never;
|
|
121
|
+
identity: undefined;
|
|
122
|
+
generated: undefined;
|
|
123
|
+
}, {}, {}>;
|
|
124
|
+
};
|
|
125
|
+
dialect: "pg";
|
|
126
|
+
}>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
export const poolInstances = pgTable("pool_instances", {
|
|
3
|
+
id: text("id").primaryKey(),
|
|
4
|
+
containerId: text("container_id").notNull(),
|
|
5
|
+
status: text("status").notNull().default("warm"),
|
|
6
|
+
tenantId: text("tenant_id"),
|
|
7
|
+
name: text("name"),
|
|
8
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
9
|
+
claimedAt: timestamp("claimed_at"),
|
|
10
|
+
});
|
|
@@ -144,7 +144,10 @@ describe("createTestContainer", () => {
|
|
|
144
144
|
serviceKeyRepo: {},
|
|
145
145
|
};
|
|
146
146
|
const hotPool = {
|
|
147
|
-
|
|
147
|
+
start: async () => ({ stop: () => { } }),
|
|
148
|
+
claim: async () => null,
|
|
149
|
+
getPoolSize: async () => 2,
|
|
150
|
+
setPoolSize: async () => { },
|
|
148
151
|
};
|
|
149
152
|
const c = createTestContainer({ fleet, crypto, stripe, gateway, hotPool });
|
|
150
153
|
expect(c.fleet).not.toBeNull();
|
|
@@ -18,6 +18,7 @@ import type { FleetManager } from "../fleet/fleet-manager.js";
|
|
|
18
18
|
import type { IProfileStore } from "../fleet/profile-store.js";
|
|
19
19
|
import type { IServiceKeyRepository } from "../gateway/service-key-repository.js";
|
|
20
20
|
import type { ProductConfig } from "../product-config/repository-types.js";
|
|
21
|
+
import type { ProductConfigService } from "../product-config/service.js";
|
|
21
22
|
import type { ProxyManagerInterface } from "../proxy/types.js";
|
|
22
23
|
import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
|
|
23
24
|
import type { OrgService } from "../tenancy/org-service.js";
|
|
@@ -45,13 +46,30 @@ export interface GatewayServices {
|
|
|
45
46
|
serviceKeyRepo: IServiceKeyRepository;
|
|
46
47
|
}
|
|
47
48
|
export interface HotPoolServices {
|
|
48
|
-
/**
|
|
49
|
-
|
|
49
|
+
/** Start the pool manager (replenish loop + cleanup). */
|
|
50
|
+
start: () => Promise<{
|
|
51
|
+
stop: () => void;
|
|
52
|
+
}>;
|
|
53
|
+
/** Claim a warm instance from the pool. Returns null if empty. */
|
|
54
|
+
claim: (name: string, tenantId: string, adminUser: {
|
|
55
|
+
id: string;
|
|
56
|
+
email: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}) => Promise<{
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
subdomain: string;
|
|
62
|
+
} | null>;
|
|
63
|
+
/** Get current pool size from DB. */
|
|
64
|
+
getPoolSize: () => Promise<number>;
|
|
65
|
+
/** Set pool size in DB. */
|
|
66
|
+
setPoolSize: (size: number) => Promise<void>;
|
|
50
67
|
}
|
|
51
68
|
export interface PlatformContainer {
|
|
52
69
|
db: DrizzleDb;
|
|
53
70
|
pool: Pool;
|
|
54
71
|
productConfig: ProductConfig;
|
|
72
|
+
productConfigService: ProductConfigService;
|
|
55
73
|
creditLedger: ILedger;
|
|
56
74
|
orgMemberRepo: IOrgMemberRepository;
|
|
57
75
|
orgService: OrgService;
|
package/dist/server/container.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function buildContainer(bootConfig) {
|
|
|
36
36
|
await migrate(db, { migrationsFolder });
|
|
37
37
|
// 4. Bootstrap product config from DB (auto-seeds from presets if needed)
|
|
38
38
|
const { platformBoot } = await import("../product-config/boot.js");
|
|
39
|
-
const { config: productConfig } = await platformBoot({ slug: bootConfig.slug, db });
|
|
39
|
+
const { config: productConfig, service: productConfigService } = await platformBoot({ slug: bootConfig.slug, db });
|
|
40
40
|
// 5. Credit ledger
|
|
41
41
|
const { DrizzleLedger } = await import("../credits/ledger.js");
|
|
42
42
|
const creditLedger = new DrizzleLedger(db);
|
|
@@ -115,12 +115,12 @@ export async function buildContainer(bootConfig) {
|
|
|
115
115
|
const serviceKeyRepo = new DrizzleServiceKeyRepository(db);
|
|
116
116
|
gateway = { serviceKeyRepo };
|
|
117
117
|
}
|
|
118
|
-
//
|
|
119
|
-
const
|
|
120
|
-
return {
|
|
118
|
+
// 12. Build the container (hotPool bound after construction)
|
|
119
|
+
const result = {
|
|
121
120
|
db,
|
|
122
121
|
pool,
|
|
123
122
|
productConfig,
|
|
123
|
+
productConfigService,
|
|
124
124
|
creditLedger,
|
|
125
125
|
orgMemberRepo,
|
|
126
126
|
orgService,
|
|
@@ -129,6 +129,21 @@ export async function buildContainer(bootConfig) {
|
|
|
129
129
|
crypto,
|
|
130
130
|
stripe,
|
|
131
131
|
gateway,
|
|
132
|
-
hotPool,
|
|
132
|
+
hotPool: null,
|
|
133
133
|
};
|
|
134
|
+
// Bind hot pool after container construction (closures need the full container)
|
|
135
|
+
if (bootConfig.features.hotPool && fleet) {
|
|
136
|
+
const { startHotPool, setPoolSize: setSize, getPoolSize: getSize } = await import("./services/hot-pool.js");
|
|
137
|
+
const { claimPoolInstance } = await import("./services/hot-pool-claim.js");
|
|
138
|
+
const { DrizzlePoolRepository } = await import("./services/pool-repository.js");
|
|
139
|
+
const poolRepo = new DrizzlePoolRepository(pool);
|
|
140
|
+
const hotPoolConfig = { provisionSecret: bootConfig.provisionSecret };
|
|
141
|
+
result.hotPool = {
|
|
142
|
+
start: () => startHotPool(result, poolRepo, hotPoolConfig),
|
|
143
|
+
claim: (name, tenantId, adminUser) => claimPoolInstance(result, poolRepo, name, tenantId, adminUser, hotPoolConfig),
|
|
144
|
+
getPoolSize: () => getSize(poolRepo),
|
|
145
|
+
setPoolSize: (size) => setSize(poolRepo, size),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
134
149
|
}
|
package/dist/server/lifecycle.js
CHANGED
|
@@ -26,6 +26,16 @@ export async function startBackgroundServices(container) {
|
|
|
26
26
|
// Non-fatal — proxy sync will retry on next health tick
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
// Hot pool manager (if enabled)
|
|
30
|
+
if (container.hotPool) {
|
|
31
|
+
try {
|
|
32
|
+
const poolHandles = await container.hotPool.start();
|
|
33
|
+
handles.unsubscribes.push(poolHandles.stop);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Non-fatal — pool will be empty but claiming falls back to cold create
|
|
37
|
+
}
|
|
38
|
+
}
|
|
29
39
|
return handles;
|
|
30
40
|
}
|
|
31
41
|
// ---------------------------------------------------------------------------
|
|
@@ -107,5 +107,23 @@ export declare function createAdminRouter(container: PlatformContainer, config?:
|
|
|
107
107
|
};
|
|
108
108
|
meta: object;
|
|
109
109
|
}>;
|
|
110
|
+
getPoolConfig: import("@trpc/server").TRPCQueryProcedure<{
|
|
111
|
+
input: void;
|
|
112
|
+
output: {
|
|
113
|
+
enabled: boolean;
|
|
114
|
+
poolSize: number;
|
|
115
|
+
warmCount: number;
|
|
116
|
+
};
|
|
117
|
+
meta: object;
|
|
118
|
+
}>;
|
|
119
|
+
setPoolSize: import("@trpc/server").TRPCMutationProcedure<{
|
|
120
|
+
input: {
|
|
121
|
+
size: number;
|
|
122
|
+
};
|
|
123
|
+
output: {
|
|
124
|
+
poolSize: number;
|
|
125
|
+
};
|
|
126
|
+
meta: object;
|
|
127
|
+
}>;
|
|
110
128
|
}>>;
|
|
111
129
|
export {};
|
|
@@ -269,5 +269,26 @@ export function createAdminRouter(container, config) {
|
|
|
269
269
|
orgCount,
|
|
270
270
|
};
|
|
271
271
|
}),
|
|
272
|
+
// -----------------------------------------------------------------------
|
|
273
|
+
// Hot pool config (DB-driven, admin-only)
|
|
274
|
+
// -----------------------------------------------------------------------
|
|
275
|
+
getPoolConfig: adminProcedure.query(async () => {
|
|
276
|
+
if (!container.hotPool) {
|
|
277
|
+
return { enabled: false, poolSize: 0, warmCount: 0 };
|
|
278
|
+
}
|
|
279
|
+
const poolSize = await container.hotPool.getPoolSize();
|
|
280
|
+
const warmRes = await container.pool.query("SELECT COUNT(*)::int AS count FROM pool_instances WHERE status = 'warm'");
|
|
281
|
+
const warmCount = Number(warmRes.rows[0]?.count ?? 0);
|
|
282
|
+
return { enabled: true, poolSize, warmCount };
|
|
283
|
+
}),
|
|
284
|
+
setPoolSize: adminProcedure
|
|
285
|
+
.input(z.object({ size: z.number().int().min(0).max(50) }))
|
|
286
|
+
.mutation(async ({ input }) => {
|
|
287
|
+
if (!container.hotPool) {
|
|
288
|
+
throw new Error("Hot pool not enabled");
|
|
289
|
+
}
|
|
290
|
+
await container.hotPool.setPoolSize(input.size);
|
|
291
|
+
return { poolSize: input.size };
|
|
292
|
+
}),
|
|
272
293
|
});
|
|
273
294
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic hot pool claiming.
|
|
3
|
+
*
|
|
4
|
+
* Uses IPoolRepository for all DB operations. No raw pool.query().
|
|
5
|
+
*/
|
|
6
|
+
import type { PlatformContainer } from "../container.js";
|
|
7
|
+
import type { IPoolRepository } from "./pool-repository.js";
|
|
8
|
+
export interface ClaimConfig {
|
|
9
|
+
/** Shared secret for provision auth. */
|
|
10
|
+
provisionSecret: string;
|
|
11
|
+
/** Container name prefix. Default: "wopr". */
|
|
12
|
+
containerPrefix?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ClaimAdminUser {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ClaimResult {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
subdomain: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Claim a warm pool instance, rename it, create a fleet profile,
|
|
26
|
+
* and register the proxy route.
|
|
27
|
+
*
|
|
28
|
+
* Returns the claim result on success, or null if the pool is empty.
|
|
29
|
+
*/
|
|
30
|
+
export declare function claimPoolInstance(container: PlatformContainer, repo: IPoolRepository, name: string, tenantId: string, _adminUser: ClaimAdminUser, config?: ClaimConfig): Promise<ClaimResult | null>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic hot pool claiming.
|
|
3
|
+
*
|
|
4
|
+
* Uses IPoolRepository for all DB operations. No raw pool.query().
|
|
5
|
+
*/
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { logger } from "../../config/logger.js";
|
|
8
|
+
import { replenishPool } from "./hot-pool.js";
|
|
9
|
+
/**
|
|
10
|
+
* Claim a warm pool instance, rename it, create a fleet profile,
|
|
11
|
+
* and register the proxy route.
|
|
12
|
+
*
|
|
13
|
+
* Returns the claim result on success, or null if the pool is empty.
|
|
14
|
+
*/
|
|
15
|
+
export async function claimPoolInstance(container, repo, name, tenantId, _adminUser, config) {
|
|
16
|
+
if (!container.fleet)
|
|
17
|
+
throw new Error("Fleet services required for pool claim");
|
|
18
|
+
const pc = container.productConfig;
|
|
19
|
+
const containerPort = pc.fleet?.containerPort ?? 3100;
|
|
20
|
+
const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
|
|
21
|
+
const platformDomain = pc.product?.domain ?? "localhost";
|
|
22
|
+
const prefix = config?.containerPrefix ?? "wopr";
|
|
23
|
+
// ---- Step 1: Atomically claim a warm instance ----
|
|
24
|
+
const claimed = await repo.claimWarm(tenantId, name);
|
|
25
|
+
if (!claimed)
|
|
26
|
+
return null;
|
|
27
|
+
const { id: instanceId, containerId } = claimed;
|
|
28
|
+
// ---- Step 2: Rename Docker container ----
|
|
29
|
+
const docker = container.fleet.docker;
|
|
30
|
+
const containerName = `${prefix}-${name}`;
|
|
31
|
+
try {
|
|
32
|
+
const c = docker.getContainer(containerId);
|
|
33
|
+
await c.rename({ name: containerName });
|
|
34
|
+
logger.info(`Pool claim: renamed container to ${containerName}`);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
logger.error("Pool claim: rename failed", { error: err.message });
|
|
38
|
+
await repo.markDead(instanceId);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// ---- Step 3: Create fleet profile ----
|
|
42
|
+
const serviceKeyRepo = container.fleet.serviceKeyRepo;
|
|
43
|
+
const gatewayKey = serviceKeyRepo ? await serviceKeyRepo.generate(tenantId, instanceId) : crypto.randomUUID();
|
|
44
|
+
const store = container.fleet.profileStore;
|
|
45
|
+
const profile = {
|
|
46
|
+
id: instanceId,
|
|
47
|
+
name,
|
|
48
|
+
tenantId,
|
|
49
|
+
image: containerImage,
|
|
50
|
+
description: `Managed instance: ${name}`,
|
|
51
|
+
env: {
|
|
52
|
+
PORT: String(containerPort),
|
|
53
|
+
HOST: "0.0.0.0",
|
|
54
|
+
NODE_ENV: "production",
|
|
55
|
+
PROVISION_SECRET: config?.provisionSecret ?? "",
|
|
56
|
+
BETTER_AUTH_SECRET: randomBytes(32).toString("hex"),
|
|
57
|
+
DATA_HOME: "/data",
|
|
58
|
+
HOSTED_MODE: "true",
|
|
59
|
+
DEPLOYMENT_MODE: "hosted_proxy",
|
|
60
|
+
DEPLOYMENT_EXPOSURE: "private",
|
|
61
|
+
MIGRATION_AUTO_APPLY: "true",
|
|
62
|
+
GATEWAY_KEY: gatewayKey,
|
|
63
|
+
},
|
|
64
|
+
restartPolicy: "unless-stopped",
|
|
65
|
+
releaseChannel: "stable",
|
|
66
|
+
updatePolicy: "manual",
|
|
67
|
+
};
|
|
68
|
+
await store.save(profile);
|
|
69
|
+
logger.info(`Pool claim: saved fleet profile for ${name} (${instanceId})`);
|
|
70
|
+
// ---- Step 4: Register proxy route ----
|
|
71
|
+
try {
|
|
72
|
+
if (container.fleet.proxy.addRoute) {
|
|
73
|
+
await container.fleet.proxy.addRoute({
|
|
74
|
+
instanceId,
|
|
75
|
+
subdomain: name,
|
|
76
|
+
upstreamHost: containerName,
|
|
77
|
+
upstreamPort: containerPort,
|
|
78
|
+
healthy: true,
|
|
79
|
+
});
|
|
80
|
+
logger.info(`Pool claim: registered proxy route ${name}.${platformDomain}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
logger.error("Pool claim: proxy route registration failed", { error: err.message });
|
|
85
|
+
}
|
|
86
|
+
// ---- Step 5: Replenish pool in background ----
|
|
87
|
+
replenishPool(container, repo, { provisionSecret: config?.provisionSecret ?? "" }).catch((err) => {
|
|
88
|
+
logger.error("Pool replenish after claim failed", { error: err.message });
|
|
89
|
+
});
|
|
90
|
+
const subdomain = `${name}.${platformDomain}`;
|
|
91
|
+
return { id: instanceId, name, subdomain };
|
|
92
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
import type { PlatformContainer } from "../container.js";
|
|
11
|
+
import type { IPoolRepository } from "./pool-repository.js";
|
|
12
|
+
export interface HotPoolConfig {
|
|
13
|
+
/** Shared secret for provision auth between platform and managed instances. */
|
|
14
|
+
provisionSecret: string;
|
|
15
|
+
/** Replenish interval in ms. Default: 60_000. */
|
|
16
|
+
replenishIntervalMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface HotPoolHandles {
|
|
19
|
+
replenishTimer: ReturnType<typeof setInterval>;
|
|
20
|
+
stop: () => void;
|
|
21
|
+
}
|
|
22
|
+
export declare function getPoolSize(repo: IPoolRepository): Promise<number>;
|
|
23
|
+
export declare function setPoolSize(repo: IPoolRepository, size: number): Promise<void>;
|
|
24
|
+
export declare function replenishPool(container: PlatformContainer, repo: IPoolRepository, config: HotPoolConfig): Promise<void>;
|
|
25
|
+
export declare function startHotPool(container: PlatformContainer, repo: IPoolRepository, config: HotPoolConfig): Promise<HotPoolHandles>;
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
import { logger } from "../../config/logger.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Pool size — delegates to repository
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
export async function getPoolSize(repo) {
|
|
15
|
+
return repo.getPoolSize();
|
|
16
|
+
}
|
|
17
|
+
export async function setPoolSize(repo, size) {
|
|
18
|
+
return repo.setPoolSize(size);
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Warm container management
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
async function createWarmContainer(container, repo, config) {
|
|
24
|
+
if (!container.fleet)
|
|
25
|
+
throw new Error("Fleet services required for hot pool");
|
|
26
|
+
const pc = container.productConfig;
|
|
27
|
+
const containerImage = pc.fleet?.containerImage ?? "ghcr.io/wopr-network/platform:latest";
|
|
28
|
+
const containerPort = pc.fleet?.containerPort ?? 3100;
|
|
29
|
+
const provisionSecret = config.provisionSecret;
|
|
30
|
+
const dockerNetwork = pc.fleet?.dockerNetwork ?? "";
|
|
31
|
+
const docker = container.fleet.docker;
|
|
32
|
+
const id = crypto.randomUUID();
|
|
33
|
+
const containerName = `pool-${id.slice(0, 8)}`;
|
|
34
|
+
const volumeName = `pool-${id.slice(0, 8)}`;
|
|
35
|
+
try {
|
|
36
|
+
// Init volume permissions
|
|
37
|
+
const init = await docker.createContainer({
|
|
38
|
+
Image: containerImage,
|
|
39
|
+
Entrypoint: ["/bin/sh", "-c"],
|
|
40
|
+
Cmd: ["chown -R 999:999 /data"],
|
|
41
|
+
User: "root",
|
|
42
|
+
HostConfig: { Binds: [`${volumeName}:/data`] },
|
|
43
|
+
});
|
|
44
|
+
await init.start();
|
|
45
|
+
await init.wait();
|
|
46
|
+
await init.remove();
|
|
47
|
+
const warmContainer = await docker.createContainer({
|
|
48
|
+
Image: containerImage,
|
|
49
|
+
name: containerName,
|
|
50
|
+
Env: [`PORT=${containerPort}`, `PROVISION_SECRET=${provisionSecret}`, "HOME=/data"],
|
|
51
|
+
HostConfig: {
|
|
52
|
+
Binds: [`${volumeName}:/data`],
|
|
53
|
+
RestartPolicy: { Name: "unless-stopped" },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
await warmContainer.start();
|
|
57
|
+
if (dockerNetwork) {
|
|
58
|
+
const network = docker.getNetwork(dockerNetwork);
|
|
59
|
+
await network.connect({ Container: warmContainer.id });
|
|
60
|
+
}
|
|
61
|
+
await repo.insertWarm(id, warmContainer.id);
|
|
62
|
+
logger.info(`Hot pool: created warm container ${containerName} (${id})`);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
logger.error("Hot pool: failed to create warm container", {
|
|
66
|
+
error: err.message,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function replenishPool(container, repo, config) {
|
|
71
|
+
const desired = await repo.getPoolSize();
|
|
72
|
+
const current = await repo.warmCount();
|
|
73
|
+
const deficit = desired - current;
|
|
74
|
+
if (deficit <= 0)
|
|
75
|
+
return;
|
|
76
|
+
logger.info(`Hot pool: replenishing ${deficit} container(s) (have ${current}, want ${desired})`);
|
|
77
|
+
for (let i = 0; i < deficit; i++) {
|
|
78
|
+
await createWarmContainer(container, repo, config);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function cleanupDead(container, repo) {
|
|
82
|
+
if (!container.fleet)
|
|
83
|
+
return;
|
|
84
|
+
const docker = container.fleet.docker;
|
|
85
|
+
const warmInstances = await repo.listWarm();
|
|
86
|
+
for (const instance of warmInstances) {
|
|
87
|
+
try {
|
|
88
|
+
const c = docker.getContainer(instance.containerId);
|
|
89
|
+
const info = await c.inspect();
|
|
90
|
+
if (!info.State.Running) {
|
|
91
|
+
await repo.markDead(instance.id);
|
|
92
|
+
try {
|
|
93
|
+
await c.remove({ force: true });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* already gone */
|
|
97
|
+
}
|
|
98
|
+
logger.warn(`Hot pool: marked dead container ${instance.id}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
await repo.markDead(instance.id);
|
|
103
|
+
logger.warn(`Hot pool: marked missing container ${instance.id} as dead`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await repo.deleteDead();
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Lifecycle
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
export async function startHotPool(container, repo, config) {
|
|
112
|
+
await cleanupDead(container, repo);
|
|
113
|
+
await replenishPool(container, repo, config);
|
|
114
|
+
const intervalMs = config.replenishIntervalMs ?? 60_000;
|
|
115
|
+
const replenishTimer = setInterval(async () => {
|
|
116
|
+
try {
|
|
117
|
+
await cleanupDead(container, repo);
|
|
118
|
+
await replenishPool(container, repo, config);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
logger.error("Hot pool tick failed", { error: err.message });
|
|
122
|
+
}
|
|
123
|
+
}, intervalMs);
|
|
124
|
+
logger.info("Hot pool manager started");
|
|
125
|
+
return {
|
|
126
|
+
replenishTimer,
|
|
127
|
+
stop: () => clearInterval(replenishTimer),
|
|
128
|
+
};
|
|
129
|
+
}
|