@wopr-network/platform-core 1.26.0 → 1.27.1
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/index.d.ts +1 -0
- package/dist/db/schema/index.js +1 -0
- package/dist/db/schema/tenant-update-configs.d.ts +79 -0
- package/dist/db/schema/tenant-update-configs.js +15 -0
- package/dist/fleet/__tests__/tenant-update-config-repository.test.d.ts +1 -0
- package/dist/fleet/__tests__/tenant-update-config-repository.test.js +55 -0
- package/dist/fleet/drizzle-tenant-update-config-repository.d.ts +10 -0
- package/dist/fleet/drizzle-tenant-update-config-repository.js +44 -0
- package/dist/fleet/fleet-event-emitter.d.ts +1 -1
- package/dist/fleet/index.d.ts +2 -0
- package/dist/fleet/index.js +2 -0
- package/dist/fleet/init-fleet-updater.d.ts +6 -0
- package/dist/fleet/init-fleet-updater.js +29 -4
- package/dist/fleet/services.d.ts +2 -0
- package/dist/fleet/services.js +10 -0
- package/dist/fleet/tenant-update-config-repository.d.ts +11 -0
- package/dist/fleet/tenant-update-config-repository.js +1 -0
- package/dist/trpc/fleet-update-config-router.d.ts +24 -0
- package/dist/trpc/fleet-update-config-router.js +26 -0
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/drizzle/migrations/0009_tenant_update_configs.sql +6 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/tenant-update-configs.ts +16 -0
- package/src/fleet/__tests__/tenant-update-config-repository.test.ts +69 -0
- package/src/fleet/drizzle-tenant-update-config-repository.ts +48 -0
- package/src/fleet/fleet-event-emitter.ts +8 -1
- package/src/fleet/index.ts +2 -0
- package/src/fleet/init-fleet-updater.ts +38 -3
- package/src/fleet/services.ts +13 -0
- package/src/fleet/tenant-update-config-repository.ts +12 -0
- package/src/trpc/fleet-update-config-router.ts +31 -0
- package/src/trpc/index.ts +1 -0
|
@@ -64,6 +64,7 @@ export * from "./tenant-capability-settings.js";
|
|
|
64
64
|
export * from "./tenant-customers.js";
|
|
65
65
|
export * from "./tenant-model-selection.js";
|
|
66
66
|
export * from "./tenant-status.js";
|
|
67
|
+
export * from "./tenant-update-configs.js";
|
|
67
68
|
export * from "./tenants.js";
|
|
68
69
|
export * from "./user-roles.js";
|
|
69
70
|
export * from "./vps-subscriptions.js";
|
package/dist/db/schema/index.js
CHANGED
|
@@ -64,6 +64,7 @@ export * from "./tenant-capability-settings.js";
|
|
|
64
64
|
export * from "./tenant-customers.js";
|
|
65
65
|
export * from "./tenant-model-selection.js";
|
|
66
66
|
export * from "./tenant-status.js";
|
|
67
|
+
export * from "./tenant-update-configs.js";
|
|
67
68
|
export * from "./tenants.js";
|
|
68
69
|
export * from "./user-roles.js";
|
|
69
70
|
export * from "./vps-subscriptions.js";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant update configuration — controls whether a tenant's bots
|
|
3
|
+
* are updated automatically or require manual intervention.
|
|
4
|
+
*/
|
|
5
|
+
export declare const tenantUpdateConfigs: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
6
|
+
name: "tenant_update_configs";
|
|
7
|
+
schema: undefined;
|
|
8
|
+
columns: {
|
|
9
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
10
|
+
name: "tenant_id";
|
|
11
|
+
tableName: "tenant_update_configs";
|
|
12
|
+
dataType: "string";
|
|
13
|
+
columnType: "PgText";
|
|
14
|
+
data: string;
|
|
15
|
+
driverParam: string;
|
|
16
|
+
notNull: true;
|
|
17
|
+
hasDefault: false;
|
|
18
|
+
isPrimaryKey: true;
|
|
19
|
+
isAutoincrement: false;
|
|
20
|
+
hasRuntimeDefault: false;
|
|
21
|
+
enumValues: [string, ...string[]];
|
|
22
|
+
baseColumn: never;
|
|
23
|
+
identity: undefined;
|
|
24
|
+
generated: undefined;
|
|
25
|
+
}, {}, {}>;
|
|
26
|
+
mode: import("drizzle-orm/pg-core").PgColumn<{
|
|
27
|
+
name: "mode";
|
|
28
|
+
tableName: "tenant_update_configs";
|
|
29
|
+
dataType: "string";
|
|
30
|
+
columnType: "PgText";
|
|
31
|
+
data: string;
|
|
32
|
+
driverParam: string;
|
|
33
|
+
notNull: true;
|
|
34
|
+
hasDefault: true;
|
|
35
|
+
isPrimaryKey: false;
|
|
36
|
+
isAutoincrement: false;
|
|
37
|
+
hasRuntimeDefault: false;
|
|
38
|
+
enumValues: [string, ...string[]];
|
|
39
|
+
baseColumn: never;
|
|
40
|
+
identity: undefined;
|
|
41
|
+
generated: undefined;
|
|
42
|
+
}, {}, {}>;
|
|
43
|
+
preferredHourUtc: import("drizzle-orm/pg-core").PgColumn<{
|
|
44
|
+
name: "preferred_hour_utc";
|
|
45
|
+
tableName: "tenant_update_configs";
|
|
46
|
+
dataType: "number";
|
|
47
|
+
columnType: "PgInteger";
|
|
48
|
+
data: number;
|
|
49
|
+
driverParam: string | number;
|
|
50
|
+
notNull: true;
|
|
51
|
+
hasDefault: true;
|
|
52
|
+
isPrimaryKey: false;
|
|
53
|
+
isAutoincrement: false;
|
|
54
|
+
hasRuntimeDefault: false;
|
|
55
|
+
enumValues: undefined;
|
|
56
|
+
baseColumn: never;
|
|
57
|
+
identity: undefined;
|
|
58
|
+
generated: undefined;
|
|
59
|
+
}, {}, {}>;
|
|
60
|
+
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
61
|
+
name: "updated_at";
|
|
62
|
+
tableName: "tenant_update_configs";
|
|
63
|
+
dataType: "number";
|
|
64
|
+
columnType: "PgBigInt53";
|
|
65
|
+
data: number;
|
|
66
|
+
driverParam: string | number;
|
|
67
|
+
notNull: true;
|
|
68
|
+
hasDefault: false;
|
|
69
|
+
isPrimaryKey: false;
|
|
70
|
+
isAutoincrement: false;
|
|
71
|
+
hasRuntimeDefault: false;
|
|
72
|
+
enumValues: undefined;
|
|
73
|
+
baseColumn: never;
|
|
74
|
+
identity: undefined;
|
|
75
|
+
generated: undefined;
|
|
76
|
+
}, {}, {}>;
|
|
77
|
+
};
|
|
78
|
+
dialect: "pg";
|
|
79
|
+
}>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
2
|
+
/**
|
|
3
|
+
* Per-tenant update configuration — controls whether a tenant's bots
|
|
4
|
+
* are updated automatically or require manual intervention.
|
|
5
|
+
*/
|
|
6
|
+
export const tenantUpdateConfigs = pgTable("tenant_update_configs", {
|
|
7
|
+
/** Owning tenant (one config per tenant) */
|
|
8
|
+
tenantId: text("tenant_id").primaryKey(),
|
|
9
|
+
/** Update mode: 'auto' for automatic updates, 'manual' for opt-in */
|
|
10
|
+
mode: text("mode").notNull().default("manual"),
|
|
11
|
+
/** Preferred hour (0-23 UTC) for automatic updates */
|
|
12
|
+
preferredHourUtc: integer("preferred_hour_utc").notNull().default(3),
|
|
13
|
+
/** Epoch ms of last config change */
|
|
14
|
+
updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
|
|
15
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
|
|
3
|
+
import { DrizzleTenantUpdateConfigRepository } from "../drizzle-tenant-update-config-repository.js";
|
|
4
|
+
describe("DrizzleTenantUpdateConfigRepository", () => {
|
|
5
|
+
let db;
|
|
6
|
+
let pool;
|
|
7
|
+
let repo;
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
({ db, pool } = await createTestDb());
|
|
10
|
+
await beginTestTransaction(pool);
|
|
11
|
+
});
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
await endTestTransaction(pool);
|
|
14
|
+
await pool.close();
|
|
15
|
+
});
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
await rollbackTestTransaction(pool);
|
|
18
|
+
repo = new DrizzleTenantUpdateConfigRepository(db);
|
|
19
|
+
});
|
|
20
|
+
it("get returns null for unknown tenant", async () => {
|
|
21
|
+
expect(await repo.get("nonexistent")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
it("upsert creates a new config", async () => {
|
|
24
|
+
await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
|
|
25
|
+
const config = await repo.get("tenant-1");
|
|
26
|
+
expect(config).not.toBeNull();
|
|
27
|
+
expect(config?.tenantId).toBe("tenant-1");
|
|
28
|
+
expect(config?.mode).toBe("auto");
|
|
29
|
+
expect(config?.preferredHourUtc).toBe(5);
|
|
30
|
+
expect(config?.updatedAt).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
it("upsert updates an existing config", async () => {
|
|
33
|
+
await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
|
|
34
|
+
const before = await repo.get("tenant-1");
|
|
35
|
+
await repo.upsert("tenant-1", { mode: "manual", preferredHourUtc: 12 });
|
|
36
|
+
const after = await repo.get("tenant-1");
|
|
37
|
+
expect(after?.mode).toBe("manual");
|
|
38
|
+
expect(after?.preferredHourUtc).toBe(12);
|
|
39
|
+
expect(after?.updatedAt).toBeGreaterThanOrEqual(before?.updatedAt ?? 0);
|
|
40
|
+
});
|
|
41
|
+
it("listAutoEnabled returns only auto-mode tenants", async () => {
|
|
42
|
+
await repo.upsert("tenant-auto-1", { mode: "auto", preferredHourUtc: 3 });
|
|
43
|
+
await repo.upsert("tenant-auto-2", { mode: "auto", preferredHourUtc: 8 });
|
|
44
|
+
await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
|
|
45
|
+
const autoConfigs = await repo.listAutoEnabled();
|
|
46
|
+
expect(autoConfigs).toHaveLength(2);
|
|
47
|
+
expect(autoConfigs.every((c) => c.mode === "auto")).toBe(true);
|
|
48
|
+
expect(autoConfigs.map((c) => c.tenantId).sort()).toEqual(["tenant-auto-1", "tenant-auto-2"]);
|
|
49
|
+
});
|
|
50
|
+
it("listAutoEnabled returns empty array when none enabled", async () => {
|
|
51
|
+
await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
|
|
52
|
+
const autoConfigs = await repo.listAutoEnabled();
|
|
53
|
+
expect(autoConfigs).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DrizzleDb } from "../db/index.js";
|
|
2
|
+
import type { ITenantUpdateConfigRepository, TenantUpdateConfig } from "./tenant-update-config-repository.js";
|
|
3
|
+
/** Drizzle-backed implementation of ITenantUpdateConfigRepository. */
|
|
4
|
+
export declare class DrizzleTenantUpdateConfigRepository implements ITenantUpdateConfigRepository {
|
|
5
|
+
private readonly db;
|
|
6
|
+
constructor(db: DrizzleDb);
|
|
7
|
+
get(tenantId: string): Promise<TenantUpdateConfig | null>;
|
|
8
|
+
upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void>;
|
|
9
|
+
listAutoEnabled(): Promise<TenantUpdateConfig[]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { tenantUpdateConfigs } from "../db/schema/index.js";
|
|
3
|
+
/** Drizzle-backed implementation of ITenantUpdateConfigRepository. */
|
|
4
|
+
export class DrizzleTenantUpdateConfigRepository {
|
|
5
|
+
db;
|
|
6
|
+
constructor(db) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
}
|
|
9
|
+
async get(tenantId) {
|
|
10
|
+
const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.tenantId, tenantId));
|
|
11
|
+
return rows[0] ? toConfig(rows[0]) : null;
|
|
12
|
+
}
|
|
13
|
+
async upsert(tenantId, config) {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
await this.db
|
|
16
|
+
.insert(tenantUpdateConfigs)
|
|
17
|
+
.values({
|
|
18
|
+
tenantId,
|
|
19
|
+
mode: config.mode,
|
|
20
|
+
preferredHourUtc: config.preferredHourUtc,
|
|
21
|
+
updatedAt: now,
|
|
22
|
+
})
|
|
23
|
+
.onConflictDoUpdate({
|
|
24
|
+
target: tenantUpdateConfigs.tenantId,
|
|
25
|
+
set: {
|
|
26
|
+
mode: config.mode,
|
|
27
|
+
preferredHourUtc: config.preferredHourUtc,
|
|
28
|
+
updatedAt: now,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async listAutoEnabled() {
|
|
33
|
+
const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.mode, "auto"));
|
|
34
|
+
return rows.map(toConfig);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function toConfig(row) {
|
|
38
|
+
return {
|
|
39
|
+
tenantId: row.tenantId,
|
|
40
|
+
mode: row.mode,
|
|
41
|
+
preferredHourUtc: row.preferredHourUtc,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IFleetEventRepository } from "./fleet-event-repository.js";
|
|
2
|
-
export type BotEventType = "bot.started" | "bot.stopped" | "bot.created" | "bot.removed" | "bot.restarted";
|
|
2
|
+
export type BotEventType = "bot.started" | "bot.stopped" | "bot.created" | "bot.removed" | "bot.restarted" | "bot.updated" | "bot.update_failed";
|
|
3
3
|
export type NodeEventType = "node.provisioned" | "node.registered" | "node.draining" | "node.drained" | "node.deprovisioned" | "node.heartbeat_lost" | "node.returned";
|
|
4
4
|
export type FleetEventType = BotEventType | NodeEventType;
|
|
5
5
|
export interface BotFleetEvent {
|
package/dist/fleet/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export * from "./drizzle-tenant-update-config-repository.js";
|
|
1
2
|
export * from "./init-fleet-updater.js";
|
|
2
3
|
export * from "./repository-types.js";
|
|
3
4
|
export * from "./rollout-orchestrator.js";
|
|
4
5
|
export * from "./rollout-strategy.js";
|
|
5
6
|
export * from "./services.js";
|
|
7
|
+
export * from "./tenant-update-config-repository.js";
|
|
6
8
|
export * from "./types.js";
|
|
7
9
|
export * from "./volume-snapshot-manager.js";
|
package/dist/fleet/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export * from "./drizzle-tenant-update-config-repository.js";
|
|
1
2
|
export * from "./init-fleet-updater.js";
|
|
2
3
|
export * from "./repository-types.js";
|
|
3
4
|
export * from "./rollout-orchestrator.js";
|
|
4
5
|
export * from "./rollout-strategy.js";
|
|
5
6
|
export * from "./services.js";
|
|
7
|
+
export * from "./tenant-update-config-repository.js";
|
|
6
8
|
export * from "./types.js";
|
|
7
9
|
export * from "./volume-snapshot-manager.js";
|
|
@@ -12,11 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type Docker from "dockerode";
|
|
14
14
|
import type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
15
|
+
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
15
16
|
import type { FleetManager } from "./fleet-manager.js";
|
|
16
17
|
import { ImagePoller } from "./image-poller.js";
|
|
17
18
|
import type { IProfileStore } from "./profile-store.js";
|
|
18
19
|
import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
|
|
19
20
|
import { type RollingWaveOptions } from "./rollout-strategy.js";
|
|
21
|
+
import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
|
|
20
22
|
import { ContainerUpdater } from "./updater.js";
|
|
21
23
|
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
22
24
|
export interface FleetUpdaterConfig {
|
|
@@ -34,6 +36,10 @@ export interface FleetUpdaterConfig {
|
|
|
34
36
|
}) => void;
|
|
35
37
|
/** Called when a rollout completes */
|
|
36
38
|
onRolloutComplete?: (result: RolloutResult) => void;
|
|
39
|
+
/** Optional per-tenant update config repository. When provided, tenants with mode=manual are excluded. */
|
|
40
|
+
configRepo?: ITenantUpdateConfigRepository;
|
|
41
|
+
/** Optional fleet event emitter. When provided, bot.updated / bot.update_failed events are emitted. */
|
|
42
|
+
eventEmitter?: FleetEventEmitter;
|
|
37
43
|
}
|
|
38
44
|
export interface FleetUpdaterHandle {
|
|
39
45
|
poller: ImagePoller;
|
|
@@ -29,7 +29,7 @@ import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
|
29
29
|
* @param config - Optional pipeline configuration
|
|
30
30
|
*/
|
|
31
31
|
export function initFleetUpdater(docker, fleet, profileStore, profileRepo, config = {}) {
|
|
32
|
-
const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, } = config;
|
|
32
|
+
const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, configRepo, eventEmitter, } = config;
|
|
33
33
|
const poller = new ImagePoller(docker, profileStore);
|
|
34
34
|
const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
|
|
35
35
|
const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
|
|
@@ -40,10 +40,35 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
|
|
|
40
40
|
strategy,
|
|
41
41
|
getUpdatableProfiles: async () => {
|
|
42
42
|
const profiles = await profileRepo.list();
|
|
43
|
-
|
|
43
|
+
const nonManualPolicy = profiles.filter((p) => p.updatePolicy !== "manual");
|
|
44
|
+
if (!configRepo)
|
|
45
|
+
return nonManualPolicy;
|
|
46
|
+
// Filter out tenants whose per-tenant config is set to manual
|
|
47
|
+
const results = await Promise.all(nonManualPolicy.map(async (p) => {
|
|
48
|
+
const tenantCfg = await configRepo.get(p.tenantId);
|
|
49
|
+
// If tenant has an explicit config with mode=manual, exclude
|
|
50
|
+
if (tenantCfg && tenantCfg.mode === "manual")
|
|
51
|
+
return null;
|
|
52
|
+
return p;
|
|
53
|
+
}));
|
|
54
|
+
return results.filter((p) => p !== null);
|
|
55
|
+
},
|
|
56
|
+
onBotUpdated: (result) => {
|
|
57
|
+
// Emit fleet events if emitter is provided
|
|
58
|
+
if (eventEmitter) {
|
|
59
|
+
eventEmitter.emit({
|
|
60
|
+
type: result.success ? "bot.updated" : "bot.update_failed",
|
|
61
|
+
botId: result.botId,
|
|
62
|
+
tenantId: "",
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// Chain user-provided callback
|
|
67
|
+
onBotUpdated?.(result);
|
|
68
|
+
},
|
|
69
|
+
onRolloutComplete: (result) => {
|
|
70
|
+
onRolloutComplete?.(result);
|
|
44
71
|
},
|
|
45
|
-
onBotUpdated,
|
|
46
|
-
onRolloutComplete,
|
|
47
72
|
});
|
|
48
73
|
// Wire the detection → orchestration pipeline.
|
|
49
74
|
// Any digest change triggers a fleet-wide rollout because the managed image
|
package/dist/fleet/services.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { OrphanCleaner } from "./orphan-cleaner.js";
|
|
|
42
42
|
import { RecoveryOrchestrator } from "./recovery-orchestrator.js";
|
|
43
43
|
import type { IRecoveryRepository } from "./recovery-repository.js";
|
|
44
44
|
import type { IRegistrationTokenRepository } from "./registration-token-store.js";
|
|
45
|
+
import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
|
|
45
46
|
import type { IVpsRepository } from "./vps-repository.js";
|
|
46
47
|
export declare function getPool(): Pool;
|
|
47
48
|
export declare function getDb(): DrizzleDb;
|
|
@@ -71,6 +72,7 @@ export declare function getMigrationOrchestrator(): MigrationOrchestrator;
|
|
|
71
72
|
export declare function getNodeDrainer(): NodeDrainer;
|
|
72
73
|
export declare function getFleetEventEmitter(): FleetEventEmitter;
|
|
73
74
|
export declare function getFleetEventRepo(): IFleetEventRepository;
|
|
75
|
+
export declare function getTenantUpdateConfigRepo(): ITenantUpdateConfigRepository;
|
|
74
76
|
export declare function getHeartbeatWatchdog(): HeartbeatWatchdog;
|
|
75
77
|
export declare function getInferenceWatchdog(): InferenceWatchdog;
|
|
76
78
|
export declare function getDOClient(): DOClient;
|
package/dist/fleet/services.js
CHANGED
|
@@ -37,6 +37,7 @@ import { DrizzleBotProfileRepository } from "./drizzle-bot-profile-repository.js
|
|
|
37
37
|
import { DrizzleFleetEventRepository } from "./drizzle-fleet-event-repository.js";
|
|
38
38
|
import { DrizzleNodeRepository } from "./drizzle-node-repository.js";
|
|
39
39
|
import { DrizzleRecoveryRepository } from "./drizzle-recovery-repository.js";
|
|
40
|
+
import { DrizzleTenantUpdateConfigRepository } from "./drizzle-tenant-update-config-repository.js";
|
|
40
41
|
import { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
41
42
|
import { DrizzleGpuAllocationRepository } from "./gpu-allocation-repository.js";
|
|
42
43
|
import { DrizzleGpuConfigurationRepository } from "./gpu-configuration-repository.js";
|
|
@@ -95,6 +96,8 @@ let _inferenceWatchdog = null;
|
|
|
95
96
|
let _fleetEventEmitter = null;
|
|
96
97
|
// Fleet event repository
|
|
97
98
|
let _fleetEventRepo = null;
|
|
99
|
+
// Tenant update config
|
|
100
|
+
let _tenantUpdateConfigRepo = null;
|
|
98
101
|
// Infrastructure
|
|
99
102
|
let _doClient = null;
|
|
100
103
|
let _nodeProvider = null;
|
|
@@ -317,6 +320,12 @@ export function getFleetEventRepo() {
|
|
|
317
320
|
}
|
|
318
321
|
return _fleetEventRepo;
|
|
319
322
|
}
|
|
323
|
+
export function getTenantUpdateConfigRepo() {
|
|
324
|
+
if (!_tenantUpdateConfigRepo) {
|
|
325
|
+
_tenantUpdateConfigRepo = new DrizzleTenantUpdateConfigRepository(getDb());
|
|
326
|
+
}
|
|
327
|
+
return _tenantUpdateConfigRepo;
|
|
328
|
+
}
|
|
320
329
|
// ---------------------------------------------------------------------------
|
|
321
330
|
// HeartbeatWatchdog
|
|
322
331
|
// ---------------------------------------------------------------------------
|
|
@@ -694,6 +703,7 @@ export function _resetForTest() {
|
|
|
694
703
|
_inferenceWatchdog = null;
|
|
695
704
|
_fleetEventRepo = null;
|
|
696
705
|
_fleetEventEmitter = null;
|
|
706
|
+
_tenantUpdateConfigRepo = null;
|
|
697
707
|
_doClient = null;
|
|
698
708
|
_nodeProvider = null;
|
|
699
709
|
_nodeProvisioner = null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface TenantUpdateConfig {
|
|
2
|
+
tenantId: string;
|
|
3
|
+
mode: "auto" | "manual";
|
|
4
|
+
preferredHourUtc: number;
|
|
5
|
+
updatedAt: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ITenantUpdateConfigRepository {
|
|
8
|
+
get(tenantId: string): Promise<TenantUpdateConfig | null>;
|
|
9
|
+
upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void>;
|
|
10
|
+
listAutoEnabled(): Promise<TenantUpdateConfig[]>;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ITenantUpdateConfigRepository } from "../fleet/tenant-update-config-repository.js";
|
|
2
|
+
export declare function createFleetUpdateConfigRouter(getConfigRepo: () => ITenantUpdateConfigRepository): import("@trpc/server").TRPCBuiltRouter<{
|
|
3
|
+
ctx: import("./init.js").TRPCContext;
|
|
4
|
+
meta: object;
|
|
5
|
+
errorShape: import("@trpc/server").TRPCDefaultErrorShape;
|
|
6
|
+
transformer: false;
|
|
7
|
+
}, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
|
|
8
|
+
getUpdateConfig: import("@trpc/server").TRPCQueryProcedure<{
|
|
9
|
+
input: {
|
|
10
|
+
tenantId: string;
|
|
11
|
+
};
|
|
12
|
+
output: import("../fleet/tenant-update-config-repository.js").TenantUpdateConfig | null;
|
|
13
|
+
meta: object;
|
|
14
|
+
}>;
|
|
15
|
+
setUpdateConfig: import("@trpc/server").TRPCMutationProcedure<{
|
|
16
|
+
input: {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
mode: "manual" | "auto";
|
|
19
|
+
preferredHourUtc?: number | undefined;
|
|
20
|
+
};
|
|
21
|
+
output: void;
|
|
22
|
+
meta: object;
|
|
23
|
+
}>;
|
|
24
|
+
}>>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { logger } from "../config/logger.js";
|
|
3
|
+
import { protectedProcedure, router } from "./init.js";
|
|
4
|
+
export function createFleetUpdateConfigRouter(getConfigRepo) {
|
|
5
|
+
return router({
|
|
6
|
+
getUpdateConfig: protectedProcedure.input(z.object({ tenantId: z.string().min(1) })).query(async ({ input }) => {
|
|
7
|
+
return getConfigRepo().get(input.tenantId);
|
|
8
|
+
}),
|
|
9
|
+
setUpdateConfig: protectedProcedure
|
|
10
|
+
.input(z.object({
|
|
11
|
+
tenantId: z.string().min(1),
|
|
12
|
+
mode: z.enum(["auto", "manual"]),
|
|
13
|
+
preferredHourUtc: z.number().int().min(0).max(23).optional(),
|
|
14
|
+
}))
|
|
15
|
+
.mutation(async ({ input }) => {
|
|
16
|
+
await getConfigRepo().upsert(input.tenantId, {
|
|
17
|
+
mode: input.mode,
|
|
18
|
+
preferredHourUtc: input.preferredHourUtc ?? 3,
|
|
19
|
+
});
|
|
20
|
+
logger.info("Tenant update config changed", {
|
|
21
|
+
tenantId: input.tenantId,
|
|
22
|
+
mode: input.mode,
|
|
23
|
+
});
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/trpc/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
+
export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
|
|
1
2
|
export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
|
package/dist/trpc/index.js
CHANGED
package/package.json
CHANGED
package/src/db/schema/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export * from "./tenant-capability-settings.js";
|
|
|
64
64
|
export * from "./tenant-customers.js";
|
|
65
65
|
export * from "./tenant-model-selection.js";
|
|
66
66
|
export * from "./tenant-status.js";
|
|
67
|
+
export * from "./tenant-update-configs.js";
|
|
67
68
|
export * from "./tenants.js";
|
|
68
69
|
export * from "./user-roles.js";
|
|
69
70
|
export * from "./vps-subscriptions.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-tenant update configuration — controls whether a tenant's bots
|
|
5
|
+
* are updated automatically or require manual intervention.
|
|
6
|
+
*/
|
|
7
|
+
export const tenantUpdateConfigs = pgTable("tenant_update_configs", {
|
|
8
|
+
/** Owning tenant (one config per tenant) */
|
|
9
|
+
tenantId: text("tenant_id").primaryKey(),
|
|
10
|
+
/** Update mode: 'auto' for automatic updates, 'manual' for opt-in */
|
|
11
|
+
mode: text("mode").notNull().default("manual"),
|
|
12
|
+
/** Preferred hour (0-23 UTC) for automatic updates */
|
|
13
|
+
preferredHourUtc: integer("preferred_hour_utc").notNull().default(3),
|
|
14
|
+
/** Epoch ms of last config change */
|
|
15
|
+
updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
|
|
16
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
2
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
4
|
+
import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
|
|
5
|
+
import { DrizzleTenantUpdateConfigRepository } from "../drizzle-tenant-update-config-repository.js";
|
|
6
|
+
|
|
7
|
+
describe("DrizzleTenantUpdateConfigRepository", () => {
|
|
8
|
+
let db: DrizzleDb;
|
|
9
|
+
let pool: PGlite;
|
|
10
|
+
let repo: DrizzleTenantUpdateConfigRepository;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
({ db, pool } = await createTestDb());
|
|
14
|
+
await beginTestTransaction(pool);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await endTestTransaction(pool);
|
|
19
|
+
await pool.close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await rollbackTestTransaction(pool);
|
|
24
|
+
repo = new DrizzleTenantUpdateConfigRepository(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("get returns null for unknown tenant", async () => {
|
|
28
|
+
expect(await repo.get("nonexistent")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("upsert creates a new config", async () => {
|
|
32
|
+
await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
|
|
33
|
+
const config = await repo.get("tenant-1");
|
|
34
|
+
expect(config).not.toBeNull();
|
|
35
|
+
expect(config?.tenantId).toBe("tenant-1");
|
|
36
|
+
expect(config?.mode).toBe("auto");
|
|
37
|
+
expect(config?.preferredHourUtc).toBe(5);
|
|
38
|
+
expect(config?.updatedAt).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("upsert updates an existing config", async () => {
|
|
42
|
+
await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
|
|
43
|
+
const before = await repo.get("tenant-1");
|
|
44
|
+
|
|
45
|
+
await repo.upsert("tenant-1", { mode: "manual", preferredHourUtc: 12 });
|
|
46
|
+
const after = await repo.get("tenant-1");
|
|
47
|
+
|
|
48
|
+
expect(after?.mode).toBe("manual");
|
|
49
|
+
expect(after?.preferredHourUtc).toBe(12);
|
|
50
|
+
expect(after?.updatedAt).toBeGreaterThanOrEqual(before?.updatedAt ?? 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("listAutoEnabled returns only auto-mode tenants", async () => {
|
|
54
|
+
await repo.upsert("tenant-auto-1", { mode: "auto", preferredHourUtc: 3 });
|
|
55
|
+
await repo.upsert("tenant-auto-2", { mode: "auto", preferredHourUtc: 8 });
|
|
56
|
+
await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
|
|
57
|
+
|
|
58
|
+
const autoConfigs = await repo.listAutoEnabled();
|
|
59
|
+
expect(autoConfigs).toHaveLength(2);
|
|
60
|
+
expect(autoConfigs.every((c) => c.mode === "auto")).toBe(true);
|
|
61
|
+
expect(autoConfigs.map((c) => c.tenantId).sort()).toEqual(["tenant-auto-1", "tenant-auto-2"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("listAutoEnabled returns empty array when none enabled", async () => {
|
|
65
|
+
await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
|
|
66
|
+
const autoConfigs = await repo.listAutoEnabled();
|
|
67
|
+
expect(autoConfigs).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import type { DrizzleDb } from "../db/index.js";
|
|
3
|
+
import { tenantUpdateConfigs } from "../db/schema/index.js";
|
|
4
|
+
import type { ITenantUpdateConfigRepository, TenantUpdateConfig } from "./tenant-update-config-repository.js";
|
|
5
|
+
|
|
6
|
+
/** Drizzle-backed implementation of ITenantUpdateConfigRepository. */
|
|
7
|
+
export class DrizzleTenantUpdateConfigRepository implements ITenantUpdateConfigRepository {
|
|
8
|
+
constructor(private readonly db: DrizzleDb) {}
|
|
9
|
+
|
|
10
|
+
async get(tenantId: string): Promise<TenantUpdateConfig | null> {
|
|
11
|
+
const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.tenantId, tenantId));
|
|
12
|
+
return rows[0] ? toConfig(rows[0]) : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void> {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
await this.db
|
|
18
|
+
.insert(tenantUpdateConfigs)
|
|
19
|
+
.values({
|
|
20
|
+
tenantId,
|
|
21
|
+
mode: config.mode,
|
|
22
|
+
preferredHourUtc: config.preferredHourUtc,
|
|
23
|
+
updatedAt: now,
|
|
24
|
+
})
|
|
25
|
+
.onConflictDoUpdate({
|
|
26
|
+
target: tenantUpdateConfigs.tenantId,
|
|
27
|
+
set: {
|
|
28
|
+
mode: config.mode,
|
|
29
|
+
preferredHourUtc: config.preferredHourUtc,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async listAutoEnabled(): Promise<TenantUpdateConfig[]> {
|
|
36
|
+
const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.mode, "auto"));
|
|
37
|
+
return rows.map(toConfig);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toConfig(row: typeof tenantUpdateConfigs.$inferSelect): TenantUpdateConfig {
|
|
42
|
+
return {
|
|
43
|
+
tenantId: row.tenantId,
|
|
44
|
+
mode: row.mode as "auto" | "manual",
|
|
45
|
+
preferredHourUtc: row.preferredHourUtc,
|
|
46
|
+
updatedAt: row.updatedAt,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { logger } from "../config/logger.js";
|
|
2
2
|
import type { IFleetEventRepository } from "./fleet-event-repository.js";
|
|
3
3
|
|
|
4
|
-
export type BotEventType =
|
|
4
|
+
export type BotEventType =
|
|
5
|
+
| "bot.started"
|
|
6
|
+
| "bot.stopped"
|
|
7
|
+
| "bot.created"
|
|
8
|
+
| "bot.removed"
|
|
9
|
+
| "bot.restarted"
|
|
10
|
+
| "bot.updated"
|
|
11
|
+
| "bot.update_failed";
|
|
5
12
|
|
|
6
13
|
export type NodeEventType =
|
|
7
14
|
| "node.provisioned"
|
package/src/fleet/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export * from "./drizzle-tenant-update-config-repository.js";
|
|
1
2
|
export * from "./init-fleet-updater.js";
|
|
2
3
|
export * from "./repository-types.js";
|
|
3
4
|
export * from "./rollout-orchestrator.js";
|
|
4
5
|
export * from "./rollout-strategy.js";
|
|
5
6
|
export * from "./services.js";
|
|
7
|
+
export * from "./tenant-update-config-repository.js";
|
|
6
8
|
export * from "./types.js";
|
|
7
9
|
export * from "./volume-snapshot-manager.js";
|
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
import type Docker from "dockerode";
|
|
15
15
|
import { logger } from "../config/logger.js";
|
|
16
16
|
import type { IBotProfileRepository } from "./bot-profile-repository.js";
|
|
17
|
+
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
17
18
|
import type { FleetManager } from "./fleet-manager.js";
|
|
18
19
|
import { ImagePoller } from "./image-poller.js";
|
|
19
20
|
import type { IProfileStore } from "./profile-store.js";
|
|
20
21
|
import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
|
|
21
22
|
import { createRolloutStrategy, type RollingWaveOptions } from "./rollout-strategy.js";
|
|
23
|
+
import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
|
|
22
24
|
import { ContainerUpdater } from "./updater.js";
|
|
23
25
|
import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
|
|
24
26
|
|
|
@@ -33,6 +35,10 @@ export interface FleetUpdaterConfig {
|
|
|
33
35
|
onBotUpdated?: (result: { botId: string; success: boolean; volumeRestored: boolean }) => void;
|
|
34
36
|
/** Called when a rollout completes */
|
|
35
37
|
onRolloutComplete?: (result: RolloutResult) => void;
|
|
38
|
+
/** Optional per-tenant update config repository. When provided, tenants with mode=manual are excluded. */
|
|
39
|
+
configRepo?: ITenantUpdateConfigRepository;
|
|
40
|
+
/** Optional fleet event emitter. When provided, bot.updated / bot.update_failed events are emitted. */
|
|
41
|
+
eventEmitter?: FleetEventEmitter;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface FleetUpdaterHandle {
|
|
@@ -69,6 +75,8 @@ export function initFleetUpdater(
|
|
|
69
75
|
snapshotDir = "/data/fleet/snapshots",
|
|
70
76
|
onBotUpdated,
|
|
71
77
|
onRolloutComplete,
|
|
78
|
+
configRepo,
|
|
79
|
+
eventEmitter,
|
|
72
80
|
} = config;
|
|
73
81
|
|
|
74
82
|
const poller = new ImagePoller(docker, profileStore);
|
|
@@ -82,10 +90,37 @@ export function initFleetUpdater(
|
|
|
82
90
|
strategy,
|
|
83
91
|
getUpdatableProfiles: async () => {
|
|
84
92
|
const profiles = await profileRepo.list();
|
|
85
|
-
|
|
93
|
+
const nonManualPolicy = profiles.filter((p) => p.updatePolicy !== "manual");
|
|
94
|
+
|
|
95
|
+
if (!configRepo) return nonManualPolicy;
|
|
96
|
+
|
|
97
|
+
// Filter out tenants whose per-tenant config is set to manual
|
|
98
|
+
const results = await Promise.all(
|
|
99
|
+
nonManualPolicy.map(async (p) => {
|
|
100
|
+
const tenantCfg = await configRepo.get(p.tenantId);
|
|
101
|
+
// If tenant has an explicit config with mode=manual, exclude
|
|
102
|
+
if (tenantCfg && tenantCfg.mode === "manual") return null;
|
|
103
|
+
return p;
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
return results.filter((p) => p !== null);
|
|
107
|
+
},
|
|
108
|
+
onBotUpdated: (result) => {
|
|
109
|
+
// Emit fleet events if emitter is provided
|
|
110
|
+
if (eventEmitter) {
|
|
111
|
+
eventEmitter.emit({
|
|
112
|
+
type: result.success ? "bot.updated" : "bot.update_failed",
|
|
113
|
+
botId: result.botId,
|
|
114
|
+
tenantId: "",
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// Chain user-provided callback
|
|
119
|
+
onBotUpdated?.(result);
|
|
120
|
+
},
|
|
121
|
+
onRolloutComplete: (result) => {
|
|
122
|
+
onRolloutComplete?.(result);
|
|
86
123
|
},
|
|
87
|
-
onBotUpdated,
|
|
88
|
-
onRolloutComplete,
|
|
89
124
|
});
|
|
90
125
|
|
|
91
126
|
// Wire the detection → orchestration pipeline.
|
package/src/fleet/services.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { DrizzleBotProfileRepository } from "./drizzle-bot-profile-repository.js
|
|
|
50
50
|
import { DrizzleFleetEventRepository } from "./drizzle-fleet-event-repository.js";
|
|
51
51
|
import { DrizzleNodeRepository } from "./drizzle-node-repository.js";
|
|
52
52
|
import { DrizzleRecoveryRepository } from "./drizzle-recovery-repository.js";
|
|
53
|
+
import { DrizzleTenantUpdateConfigRepository } from "./drizzle-tenant-update-config-repository.js";
|
|
53
54
|
import { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
54
55
|
import type { IFleetEventRepository } from "./fleet-event-repository.js";
|
|
55
56
|
import type { IGpuAllocationRepository } from "./gpu-allocation-repository.js";
|
|
@@ -76,6 +77,7 @@ import type { IRecoveryRepository } from "./recovery-repository.js";
|
|
|
76
77
|
import type { IRegistrationTokenRepository } from "./registration-token-store.js";
|
|
77
78
|
import { DrizzleRegistrationTokenRepository } from "./registration-token-store.js";
|
|
78
79
|
import { DrizzleSpendingCapStore } from "./spending-cap-repository.js";
|
|
80
|
+
import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
|
|
79
81
|
import type { IVpsRepository } from "./vps-repository.js";
|
|
80
82
|
import { DrizzleVpsRepository } from "./vps-repository.js";
|
|
81
83
|
|
|
@@ -128,6 +130,9 @@ let _fleetEventEmitter: FleetEventEmitter | null = null;
|
|
|
128
130
|
// Fleet event repository
|
|
129
131
|
let _fleetEventRepo: IFleetEventRepository | null = null;
|
|
130
132
|
|
|
133
|
+
// Tenant update config
|
|
134
|
+
let _tenantUpdateConfigRepo: ITenantUpdateConfigRepository | null = null;
|
|
135
|
+
|
|
131
136
|
// Infrastructure
|
|
132
137
|
let _doClient: DOClient | null = null;
|
|
133
138
|
let _nodeProvider: INodeProvider | null = null;
|
|
@@ -403,6 +408,13 @@ export function getFleetEventRepo(): IFleetEventRepository {
|
|
|
403
408
|
return _fleetEventRepo;
|
|
404
409
|
}
|
|
405
410
|
|
|
411
|
+
export function getTenantUpdateConfigRepo(): ITenantUpdateConfigRepository {
|
|
412
|
+
if (!_tenantUpdateConfigRepo) {
|
|
413
|
+
_tenantUpdateConfigRepo = new DrizzleTenantUpdateConfigRepository(getDb());
|
|
414
|
+
}
|
|
415
|
+
return _tenantUpdateConfigRepo;
|
|
416
|
+
}
|
|
417
|
+
|
|
406
418
|
// ---------------------------------------------------------------------------
|
|
407
419
|
// HeartbeatWatchdog
|
|
408
420
|
// ---------------------------------------------------------------------------
|
|
@@ -894,6 +906,7 @@ export function _resetForTest(): void {
|
|
|
894
906
|
_inferenceWatchdog = null;
|
|
895
907
|
_fleetEventRepo = null;
|
|
896
908
|
_fleetEventEmitter = null;
|
|
909
|
+
_tenantUpdateConfigRepo = null;
|
|
897
910
|
_doClient = null;
|
|
898
911
|
_nodeProvider = null;
|
|
899
912
|
_nodeProvisioner = null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface TenantUpdateConfig {
|
|
2
|
+
tenantId: string;
|
|
3
|
+
mode: "auto" | "manual";
|
|
4
|
+
preferredHourUtc: number;
|
|
5
|
+
updatedAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ITenantUpdateConfigRepository {
|
|
9
|
+
get(tenantId: string): Promise<TenantUpdateConfig | null>;
|
|
10
|
+
upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void>;
|
|
11
|
+
listAutoEnabled(): Promise<TenantUpdateConfig[]>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { logger } from "../config/logger.js";
|
|
3
|
+
import type { ITenantUpdateConfigRepository } from "../fleet/tenant-update-config-repository.js";
|
|
4
|
+
import { protectedProcedure, router } from "./init.js";
|
|
5
|
+
|
|
6
|
+
export function createFleetUpdateConfigRouter(getConfigRepo: () => ITenantUpdateConfigRepository) {
|
|
7
|
+
return router({
|
|
8
|
+
getUpdateConfig: protectedProcedure.input(z.object({ tenantId: z.string().min(1) })).query(async ({ input }) => {
|
|
9
|
+
return getConfigRepo().get(input.tenantId);
|
|
10
|
+
}),
|
|
11
|
+
|
|
12
|
+
setUpdateConfig: protectedProcedure
|
|
13
|
+
.input(
|
|
14
|
+
z.object({
|
|
15
|
+
tenantId: z.string().min(1),
|
|
16
|
+
mode: z.enum(["auto", "manual"]),
|
|
17
|
+
preferredHourUtc: z.number().int().min(0).max(23).optional(),
|
|
18
|
+
}),
|
|
19
|
+
)
|
|
20
|
+
.mutation(async ({ input }) => {
|
|
21
|
+
await getConfigRepo().upsert(input.tenantId, {
|
|
22
|
+
mode: input.mode,
|
|
23
|
+
preferredHourUtc: input.preferredHourUtc ?? 3,
|
|
24
|
+
});
|
|
25
|
+
logger.info("Tenant update config changed", {
|
|
26
|
+
tenantId: input.tenantId,
|
|
27
|
+
mode: input.mode,
|
|
28
|
+
});
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/trpc/index.ts
CHANGED