diff --git a/config/default.json b/config/default.json index 11c88532..6a19ffba 100644 --- a/config/default.json +++ b/config/default.json @@ -1,330 +1,87 @@ { - "regions": { - "sa-saopaulo-1": { - "displayName": "São Paulo", - "srcdsHostname": "TF2-QuickServer | São Paulo", - "tvHostname": "TF2-QuickServer TV | São Paulo", - "cloudProvider": "oracle", - "homeRegion": "us-chicago-1" - }, - "sa-santiago-1": { - "displayName": "Santiago", - "srcdsHostname": "TF2-QuickServer | Santiago", - "tvHostname": "TF2-QuickServer TV | Santiago", - "cloudProvider": "oracle", - "homeRegion": "sa-santiago-1" - }, - "sa-bogota-1": { - "displayName": "Bogotá", - "srcdsHostname": "TF2-QuickServer | Bogotá", - "tvHostname": "TF2-QuickServer TV | Bogotá", - "cloudProvider": "oracle", - "homeRegion": "us-chicago-1" - }, - "us-chicago-1": { - "displayName": "Chicago", - "srcdsHostname": "TF2-QuickServer | Chicago", - "tvHostname": "TF2-QuickServer TV | Chicago", - "cloudProvider": "oracle", - "homeRegion": "us-chicago-1" - }, - "eu-frankfurt-1": { - "displayName": "Frankfurt", - "srcdsHostname": "TF2-QuickServer | Frankfurt", - "tvHostname": "TF2-QuickServer TV | Frankfurt", - "cloudProvider": "oracle", - "homeRegion": "sa-santiago-1" - }, - "ap-sydney-1": { - "displayName": "Sydney", - "srcdsHostname": "TF2-QuickServer | Sydney", - "tvHostname": "TF2-QuickServer TV | Sydney", - "cloudProvider": "oracle", - "homeRegion": "sa-santiago-1" - }, - "us-east-1-bue-1": { - "displayName": "Buenos Aires (Experimental)", - "srcdsHostname": "TF2-QuickServer | Buenos Aires", - "tvHostname": "TF2-QuickServer TV | Buenos Aires", - "cloudProvider": "aws", - "homeRegion": "us-east-1" - }, - "us-east-1-lim-1": { - "displayName": "Lima (Experimental)", - "srcdsHostname": "TF2-QuickServer | Lima", - "tvHostname": "TF2-QuickServer TV | Lima", - "cloudProvider": "aws", - "homeRegion": "us-east-1" - } + "regions": { + "sa-saopaulo-1": { + "displayName": "São Paulo", + "srcdsHostname": "TF2-QuickServer | São Paulo", + "tvHostname": "TF2-QuickServer TV | São Paulo", + "cloudProvider": "oracle", + "homeRegion": "us-chicago-1" }, - "variants": { - "default": { - "shape": "CI.Standard.E4.Flex", - "ocpu": 1, - "memory": 4, - "maxPlayers": 24, - "map": "cp_process_f12", - "svPure": 2, - "image": "sonikro/fat-tf2-standard-competitive-i386:latest" - }, - "standard-competitive": { - "displayName": "Standard Competitive", - "defaultCfgs": { - "5cp": "fbtf_6v6_5cp.cfg", - "koth": "fbtf_6v6_koth.cfg", - "pl": "rgl_HL_stopwatch.cfg", - "ultiduo": "rgl_ud_ultiduo.cfg" - } - }, - "fbtf-6v6-standard": { - "displayName": "FBTF 6v6 Standard", - "defaultCfgs": { - "5cp": "fbtf_6v6_5cp.cfg", - "koth": "fbtf_6v6_koth.cfg" - }, - "hostname": "FBTF 6v6 Standard | {region} @ TF2-QuickServer", - "guildId": "144450745662570496" - }, - "fbtf-6v6-pro": { - "displayName": "FBTF 6v6 Pro", - "defaultCfgs": { - "5cp": "fbtf_6v6_5cp_pro.cfg", - "koth": "fbtf_6v6_koth.cfg" - }, - "hostname": "FBTF 6v6 Pro | {region} @ TF2-QuickServer", - "guildId": "144450745662570496" - }, - "fbtf-9v9": { - "displayName": "FBTF 9v9", - "defaultCfgs": { - "pl": "rgl_HL_stopwatch.cfg", - "koth": "rgl_HL_koth.cfg", - "cp_steel_f12": "rgl_HL_stopwatch.cfg" - }, - "hostname": "FBTF 9v9 | {region} @ TF2-QuickServer", - "guildId": "144450745662570496" - }, - "insertcoin": { - "displayName": "InsertCoin Mixes", - "hostname": "InsertCoin Mixes | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "insertcoin_5cp.cfg", - "koth": "fbtf_6v6_koth.cfg", - "pl": "rgl_HL_stopwatch.cfg", - "ultiduo": "rgl_ud_ultiduo.cfg" - }, - "admins": [ - "STEAM_0:1:124066436", - "STEAM_0:0:674555708", - "STEAM_0:0:29546995", - "STEAM_0:0:126279196", - "STEAM_0:0:43847245", - "STEAM_0:1:609890121", - "STEAM_0:1:79034295", - "STEAM_0:1:80160413", - "STEAM_0:0:559647877", - "STEAM_0:1:66778305", - "STEAM_0:1:153691324", - "STEAM_0:1:569914170", - "STEAM_0:1:153691324", - "STEAM_0:0:598396606" - ], - "guildId": "1323509685264842752" - }, - "insertcoin-hl": { - "displayName": "InsertCoin HL", - "hostname": "InsertCoin HL | {region} @ TF2-QuickServer", - "defaultCfgs": { - "pl": "bf_hl_stopwatch.cfg", - "5cp": "bf_hl_5cp.cfg", - "koth": "insertcoin_hl_koth.cfg", - "cp_steel_f12": "bf_hl_stopwatch.cfg" - }, - "admins": [ - "STEAM_0:1:124066436", - "STEAM_0:0:674555708", - "STEAM_0:0:29546995", - "STEAM_0:0:43847245", - "STEAM_0:1:79034295", - "STEAM_0:0:559647877", - "STEAM_0:1:108717565", - "STEAM_0:1:66778305", - "STEAM_0:1:161112963", - "STEAM_0:1:83898018", - "STEAM_0:0:147529553", - "STEAM_0:1:462697690", - "STEAM_0:1:151061834", - "STEAM_0:1:454167379", - "STEAM_0:0:226161678", - "STEAM_0:0:55877960", - "STEAM_0:0:53127652", - "STEAM_0:0:23221722" - ], - "guildId": "1414690765208158328" - }, - "tf2pickup": { - "displayName": "br.tf2pickup.org", - "hostname": "br.tf2pickup.org | {region} @ TF2-QuickServer", - "image": "sonikro/fat-tf2-pickup:latest", - "emptyMinutesTerminate": 60, - "managedExternally": true, - "admins": [ - "STEAM_0:0:14581482", - "STEAM_0:0:26146577", - "STEAM_1:0:57128127", - "STEAM_0:1:25189350", - "STEAM_0:1:48369748", - "STEAM_0:1:59961573", - "STEAM_0:1:109770789", - "STEAM_0:1:11075984" - ], - "guildId": "1151219992432492677" - }, - "hlbr-tf2pickup": { - "displayName": "hl.br.tf2pickup.org", - "hostname": "hl.br.tf2pickup.org | {region} @ TF2-QuickServer", - "image": "sonikro/fat-tf2-pickup:latest", - "emptyMinutesTerminate": 60, - "managedExternally": true, - "admins": [ - "STEAM_0:1:433143455", - "STEAM_0:1:79008369", - "STEAM_0:1:64055278", - "STEAM_0:0:44243516", - "STEAM_0:1:433143455" - ], - "guildId": "929204770969907280" - }, - "mix-sa-novatos": { - "displayName": "Mix Novatos - B4nny cfg 25 min", - "hostname": "Mix SA Novatos | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "novatos_6v6_5cp.cfg", - "koth": "novatos_6v6_koth.cfg" - }, - "admins": [ - "STEAM_0:1:162898562", - "STEAM_0:0:185104239", - "STEAM_0:0:208429922", - "STEAM_0:0:453571126", - "STEAM_0:1:580470584", - "STEAM_0:0:457601451" - ], - "guildId": "1143342782396780584" - }, - "insertcoin-novatos": { - "displayName": "InsertCoin Novatos", - "hostname": "InsertCoin Novatos | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "insertcoin_5cp_novatos.cfg", - "koth": "fbtf_6v6_koth.cfg", - "pl": "rgl_HL_stopwatch.cfg", - "ultiduo": "rgl_ud_ultiduo.cfg" - }, - "admins": [ - "STEAM_0:1:124066436", - "STEAM_0:0:674555708", - "STEAM_0:0:29546995", - "STEAM_0:0:43847245", - "STEAM_0:0:23221722", - "STEAM_0:0:559647877", - "STEAM_0:1:153691324", - "STEAM_0:0:139729835", - "STEAM_0:1:454167379", - "STEAM_0:0:210475374", - "STEAM_0:1:438914332", - "STEAM_0:1:194888319", - "STEAM_0:0:210340262" - ], - "guildId": "1443412454574129224" - }, - "TFArena": { - "displayName": "TF Arena", - "hostname": "TFArena | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "tfarena_6v6_5cp.cfg", - "koth": "tfarena_6v6_koth.cfg", - "pl": "tfarena_6v6_stopwatch.cfg", - "cp_steel_2024h": "tfarena_6v6_stopwatch.cfg" - }, - "guildId": "803785792052527114" - }, - "bf-9v9": { - "displayName": "BF HL", - "hostname": "Brasil Fortress HL | {region} @ TF2-QuickServer", - "defaultCfgs": { - "pl": "bf_hl_stopwatch.cfg", - "5cp": "bf_hl_5cp.cfg", - "koth": "bf_hl_koth.cfg", - "cp_steel_f12": "bf_hl_stopwatch.cfg" - }, - "guildId": "312687261219160065" - }, - "bf-6v6-standard": { - "displayName": "BF 6v6 Standard", - "hostname": "Brasil Fortress | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "bf_6v6_5cp.cfg", - "koth": "bf_6v6_koth.cfg" - }, - "guildId": "312687261219160065" - }, - "bf-6v6-invite": { - "displayName": "BF 6v6 Invite", - "hostname": "Brasil Fortress | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "bf_6v6_5cp_invite.cfg", - "koth": "bf_6v6_koth.cfg" - }, - "guildId": "312687261219160065" - }, - "qel-4v4": { - "displayName": "QEL 4v4", - "hostname": "QEL 4v4 | {region} @ TF2-QuickServer", - "defaultCfgs": { - "5cp": "fbtf_6v6_5cp.cfg", - "koth": "qel_4s_koth.cfg" - }, - "guildId": "1291587765770649656" - }, - "mge-tf": { - "displayName": "MGE.TF", - "hostname": "MGE.TF | {region} @ TF2-QuickServer", - "guildId": "1172639313649995777", - "map": "mge_training_v8_beta4b", - "image": "sonikro/fat-mge-tf:latest", - "admins": [ - "STEAM_0:1:40459123" - ] - }, - "rgl-standard": { - "displayName": "RGL Standard", - "hostname": "RGL | {region} @ TF2-QuickServer", - "defaultCfgs": { - "pl": "rgl_HL_stopwatch.cfg", - "koth": "rgl_6s_koth.cfg", - "5cp": "rgl_6s_5cp_match_half1.cfg", - "ultiduo": "rgl_ud_ultiduo.cfg" - }, - "guildId": "137337002113761281" - }, - "rgl-pro": { - "displayName": "RGL PRO", - "hostname": "RGL Pro | {region} @ TF2-QuickServer", - "defaultCfgs": { - "pl": "rgl_HL_stopwatch.cfg", - "koth": "rgl_6s_koth_pro.cfg", - "5cp": "rgl_6s_5cp_match_pro.cfg", - "ultiduo": "rgl_ud_ultiduo.cfg" - }, - "guildId": "137337002113761281" - } + "sa-santiago-1": { + "displayName": "Santiago", + "srcdsHostname": "TF2-QuickServer | Santiago", + "tvHostname": "TF2-QuickServer TV | Santiago", + "cloudProvider": "oracle", + "homeRegion": "sa-santiago-1" }, - "discord": { - "logChannelId": "1360789887208394851", - "reportDiscordChannelId": "1378761634889076926" + "sa-bogota-1": { + "displayName": "Bogotá", + "srcdsHostname": "TF2-QuickServer | Bogotá", + "tvHostname": "TF2-QuickServer TV | Bogotá", + "cloudProvider": "oracle", + "homeRegion": "us-chicago-1" }, - "credits": { - "enabled": false + "us-chicago-1": { + "displayName": "Chicago", + "srcdsHostname": "TF2-QuickServer | Chicago", + "tvHostname": "TF2-QuickServer TV | Chicago", + "cloudProvider": "oracle", + "homeRegion": "us-chicago-1" + }, + "eu-frankfurt-1": { + "displayName": "Frankfurt", + "srcdsHostname": "TF2-QuickServer | Frankfurt", + "tvHostname": "TF2-QuickServer TV | Frankfurt", + "cloudProvider": "oracle", + "homeRegion": "sa-santiago-1" + }, + "ap-sydney-1": { + "displayName": "Sydney", + "srcdsHostname": "TF2-QuickServer | Sydney", + "tvHostname": "TF2-QuickServer TV | Sydney", + "cloudProvider": "oracle", + "homeRegion": "sa-santiago-1" + }, + "us-east-1-bue-1": { + "displayName": "Buenos Aires (Experimental)", + "srcdsHostname": "TF2-QuickServer | Buenos Aires", + "tvHostname": "TF2-QuickServer TV | Buenos Aires", + "cloudProvider": "aws", + "homeRegion": "us-east-1" + }, + "us-east-1-lim-1": { + "displayName": "Lima (Experimental)", + "srcdsHostname": "TF2-QuickServer | Lima", + "tvHostname": "TF2-QuickServer TV | Lima", + "cloudProvider": "aws", + "homeRegion": "us-east-1" + } + }, + "variants": { + "default": { + "shape": "CI.Standard.E4.Flex", + "ocpu": 1, + "memory": 4, + "maxPlayers": 24, + "map": "cp_process_f12", + "svPure": 2, + "image": "sonikro/fat-tf2-standard-competitive-i386:latest" + }, + "standard-competitive": { + "displayName": "Standard Competitive", + "defaultCfgs": { + "5cp": "fbtf_6v6_5cp.cfg", + "koth": "fbtf_6v6_koth.cfg", + "pl": "rgl_HL_stopwatch.cfg", + "ultiduo": "rgl_ud_ultiduo.cfg" + } } -} \ No newline at end of file + }, + "discord": { + "logChannelId": "1360789887208394851", + "reportDiscordChannelId": "1378761634889076926" + }, + "credits": { + "enabled": false + } +} diff --git a/migrations/20251215020719_guild_variants.ts b/migrations/20251215020719_guild_variants.ts new file mode 100644 index 00000000..fb207bc8 --- /dev/null +++ b/migrations/20251215020719_guild_variants.ts @@ -0,0 +1,21 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable("guild_variants", (table) => { + table.increments("id").primary(); + table.string("guild_id").notNullable(); + table.string("variant_name").notNullable(); + table.string("display_name").nullable(); + table.string("hostname").nullable(); + table.json("default_cfgs").nullable(); + table.json("admins").nullable(); + table.string("image").nullable(); + table.timestamps(true, true); + table.unique(["guild_id", "variant_name"]); + table.index("guild_id"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("guild_variants"); +} diff --git a/migrations/20251215021726_add_guildid_to_servers.ts b/migrations/20251215021726_add_guildid_to_servers.ts new file mode 100644 index 00000000..ea92735c --- /dev/null +++ b/migrations/20251215021726_add_guildid_to_servers.ts @@ -0,0 +1,14 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable("servers", (table) => { + table.string("guild_id").nullable(); + table.index("guild_id"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable("servers", (table) => { + table.dropColumn("guild_id"); + }); +} diff --git a/migrations/20251215032644_populate_variants_from_config.ts b/migrations/20251215032644_populate_variants_from_config.ts new file mode 100644 index 00000000..cec745cb --- /dev/null +++ b/migrations/20251215032644_populate_variants_from_config.ts @@ -0,0 +1,29 @@ +import type { Knex } from "knex"; +import config from "config"; + +export async function up(knex: Knex): Promise { + const variants = config.get>('variants'); + const defaultVariant = variants['default']; + + for (const [variantName, variantData] of Object.entries(variants)) { + if (variantName === 'default') continue; + if (variantName === 'standard-competitive' && !variantData.guildId) continue; + + const guildId = variantData.guildId; + if (!guildId) continue; + + await knex('guild_variants').insert({ + guild_id: guildId, + variant_name: variantName, + display_name: variantData.displayName || null, + hostname: variantData.hostname || null, + default_cfgs: variantData.defaultCfgs ? JSON.stringify(variantData.defaultCfgs) : null, + admins: variantData.admins ? JSON.stringify(variantData.admins) : null, + image: variantData.image || null, + }); + } +} + +export async function down(knex: Knex): Promise { + await knex('guild_variants').del(); +} diff --git a/packages/core/index.ts b/packages/core/index.ts index 912d0d71..dc72dae8 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -17,6 +17,7 @@ export * from './src/repository/ServerActivityRepository'; export * from './src/repository/UserRepository'; export * from './src/repository/UserBanRepository'; export * from './src/repository/UserCreditsRepository'; +export * from './src/repository/VariantRepository'; // Services export * from './src/services/BackgroundTaskQueue'; @@ -37,11 +38,14 @@ export * from './src/services/TF2ServerReadinessService'; export * from './src/usecase/ConsumeCreditsFromRunningServers'; export * from './src/usecase/CreateCreditsPurchaseOrder'; export * from './src/usecase/CreateServerForUser'; +export * from './src/usecase/CreateVariant'; export * from './src/usecase/DeleteServer'; export * from './src/usecase/DeleteServerForUser'; +export * from './src/usecase/DeleteVariant'; export * from './src/usecase/GenerateMonthlyUsageReport'; export * from './src/usecase/GetServerStatus'; export * from './src/usecase/GetUserServers'; +export * from './src/usecase/GetVariantsForGuild'; export * from './src/usecase/HandleOrderPaid'; export * from './src/usecase/SetUserData'; export * from './src/usecase/TerminateEmptyServers'; diff --git a/packages/core/src/domain/DeployedServer.ts b/packages/core/src/domain/DeployedServer.ts index 6e4b88be..12f19109 100644 --- a/packages/core/src/domain/DeployedServer.ts +++ b/packages/core/src/domain/DeployedServer.ts @@ -16,6 +16,7 @@ export interface Server { tvPassword?: string; createdAt?: Date; createdBy?: string; + guildId?: string; status?: ServerStatus; logSecret?: number; } \ No newline at end of file diff --git a/packages/core/src/domain/GuildVariant.ts b/packages/core/src/domain/GuildVariant.ts new file mode 100644 index 00000000..fcc4c712 --- /dev/null +++ b/packages/core/src/domain/GuildVariant.ts @@ -0,0 +1,12 @@ +export type GuildVariant = { + id?: number; + guildId: string; + variantName: string; + displayName?: string; + hostname?: string; + defaultCfgs?: Record; + admins?: string[]; + image?: string; + createdAt?: Date; + updatedAt?: Date; +} diff --git a/packages/core/src/domain/Variant.ts b/packages/core/src/domain/Variant.ts index 8912f13b..99c718ff 100644 --- a/packages/core/src/domain/Variant.ts +++ b/packages/core/src/domain/Variant.ts @@ -1,5 +1,3 @@ -import config from "config"; - export type Variant = string; export type VariantConfig = { @@ -43,21 +41,4 @@ export type VariantConfig = { * @default false */ managedExternally?: boolean; -} - -export function getVariantConfig(variant: Variant) { - const defaultSettings = config.get(`variants.default`); - const variantConfig = config.get(`variants.${variant}`); // This will throw if the variant is not found - return { - ...defaultSettings, - ...variantConfig, - }; -} - -export function getVariantConfigs() { - const variants = config.get>(`variants`); - return Object.keys(variants).filter(it => it !== "default").map(variant => ({ - name: variant, - config: getVariantConfig(variant), - })); } \ No newline at end of file diff --git a/packages/core/src/domain/index.ts b/packages/core/src/domain/index.ts index 788a615c..1e362b6a 100644 --- a/packages/core/src/domain/index.ts +++ b/packages/core/src/domain/index.ts @@ -11,6 +11,7 @@ export * from "./User"; export * from "./CreditOrder"; export * from "./CreditOrderRequest"; export * from "./GuildParameters"; +export * from "./GuildVariant"; export * from "./Cost"; export * from "./DateRange"; export * from "./ServerActivity"; diff --git a/packages/core/src/models/DeploymentContext.ts b/packages/core/src/models/DeploymentContext.ts index 1a9a7cd4..64f4cabc 100644 --- a/packages/core/src/models/DeploymentContext.ts +++ b/packages/core/src/models/DeploymentContext.ts @@ -11,6 +11,7 @@ export class DeploymentContext { variantName, statusUpdater, sourcemodAdminSteamId, + guildId, extraEnvs = {} }: { serverId: string; @@ -18,6 +19,7 @@ export class DeploymentContext { variantName: Variant; statusUpdater: StatusUpdater; sourcemodAdminSteamId?: string; + guildId?: string; extraEnvs?: Record; }) { this.serverId = serverId; @@ -25,6 +27,7 @@ export class DeploymentContext { this.variantName = variantName; this.statusUpdater = statusUpdater; this.sourcemodAdminSteamId = sourcemodAdminSteamId; + this.guildId = guildId; this.extraEnvs = extraEnvs; } @@ -33,6 +36,7 @@ export class DeploymentContext { public readonly variantName: Variant; public readonly statusUpdater: StatusUpdater; public readonly sourcemodAdminSteamId?: string; + public readonly guildId?: string; public readonly extraEnvs: Record; /** diff --git a/packages/core/src/repository/VariantRepository.ts b/packages/core/src/repository/VariantRepository.ts new file mode 100644 index 00000000..ba21bcba --- /dev/null +++ b/packages/core/src/repository/VariantRepository.ts @@ -0,0 +1,17 @@ +import { GuildVariant } from "../domain/GuildVariant"; +import { Variant, VariantConfig } from "../domain/Variant"; + +export type VariantWithConfig = { + name: string; + config: VariantConfig; +} + +export interface VariantRepository { + create(params: { variant: GuildVariant }): Promise; + findByGuildIdAndName(params: { guildId: string; variantName: string }): Promise; + findByGuildId(params: { guildId: string }): Promise; + deleteByGuildIdAndName(params: { guildId: string; variantName: string }): Promise; + getVariantConfig(params: { variant: Variant; guildId?: string }): Promise; + getVariantConfigs(params: { guildId?: string }): Promise; + findAll(): Promise; +} diff --git a/packages/core/src/services/ServerManager.ts b/packages/core/src/services/ServerManager.ts index 75588d02..b2d0aed5 100644 --- a/packages/core/src/services/ServerManager.ts +++ b/packages/core/src/services/ServerManager.ts @@ -11,6 +11,7 @@ export interface ServerManager { variantName: Variant, statusUpdater: StatusUpdater, sourcemodAdminSteamId?: string, + guildId?: string, extraEnvs?: Record, }): Promise; diff --git a/packages/core/src/usecase/CreateServerForUser.ts b/packages/core/src/usecase/CreateServerForUser.ts index 27b3abe9..9ad4b61b 100644 --- a/packages/core/src/usecase/CreateServerForUser.ts +++ b/packages/core/src/usecase/CreateServerForUser.ts @@ -111,6 +111,7 @@ export class CreateServerForUser { region: args.region, variant: args.variantName, createdBy: args.creatorId, + guildId: args.guildId, status: "pending", } as Server, trx); }); @@ -122,6 +123,7 @@ export class CreateServerForUser { variantName: args.variantName, sourcemodAdminSteamId: user.steamIdText, serverId, + guildId: args.guildId, extraEnvs: guildParameters?.environment_variables || {}, statusUpdater }); @@ -134,6 +136,7 @@ export class CreateServerForUser { await serverRepository.upsertServer({ ...server, createdBy: args.creatorId, + guildId: args.guildId, status: "ready" }); return server; diff --git a/packages/core/src/usecase/CreateVariant.test.ts b/packages/core/src/usecase/CreateVariant.test.ts new file mode 100644 index 00000000..2b5ca8ac --- /dev/null +++ b/packages/core/src/usecase/CreateVariant.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { CreateVariant } from "./CreateVariant"; +import { VariantRepository } from "../repository/VariantRepository"; +import { GuildVariant } from "../domain/GuildVariant"; + +describe("CreateVariant", () => { + const makeSut = () => { + const variantRepository = mock(); + const sut = new CreateVariant({ variantRepository }); + return { sut, variantRepository }; + }; + + it("should create a new variant when it does not exist", async () => { + const { sut, variantRepository } = makeSut(); + + const params = { + guildId: "guild123", + variantName: "custom-variant", + displayName: "Custom Variant", + hostname: "Custom Server | {region}", + defaultCfgs: { "5cp": "custom.cfg" }, + admins: ["STEAM_0:1:12345"], + image: "custom/image:latest", + emptyMinutesTerminate: 15, + }; + + when(variantRepository.findByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(null); + + const expectedVariant: GuildVariant = { + id: 1, + guildId: params.guildId, + variantName: params.variantName, + displayName: params.displayName, + hostname: params.hostname, + defaultCfgs: params.defaultCfgs, + admins: params.admins, + image: params.image, + emptyMinutesTerminate: params.emptyMinutesTerminate, + }; + + when(variantRepository.create) + .calledWith({ variant: expect.objectContaining({ guildId: params.guildId }) }) + .mockResolvedValue(expectedVariant); + + const result = await sut.execute(params); + + expect(result).toEqual(expectedVariant); + expect(variantRepository.create).toHaveBeenCalledWith({ + variant: expect.objectContaining({ + guildId: params.guildId, + variantName: params.variantName, + displayName: params.displayName, + }), + }); + }); + + it("should throw error when variant already exists", async () => { + const { sut, variantRepository } = makeSut(); + + const params = { + guildId: "guild123", + variantName: "existing-variant", + displayName: "Existing Variant", + }; + + const existingVariant: GuildVariant = { + id: 1, + guildId: params.guildId, + variantName: params.variantName, + displayName: params.displayName, + }; + + when(variantRepository.findByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(existingVariant); + + await expect(sut.execute(params)).rejects.toThrow( + `Variant ${params.variantName} already exists for this guild` + ); + expect(variantRepository.create).not.toHaveBeenCalled(); + }); + + it("should create variant with minimal required fields", async () => { + const { sut, variantRepository } = makeSut(); + + const params = { + guildId: "guild123", + variantName: "minimal-variant", + displayName: "Minimal Variant", + }; + + when(variantRepository.findByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(null); + + const expectedVariant: GuildVariant = { + id: 1, + guildId: params.guildId, + variantName: params.variantName, + displayName: params.displayName, + }; + + when(variantRepository.create) + .calledWith({ variant: expect.objectContaining({ guildId: params.guildId }) }) + .mockResolvedValue(expectedVariant); + + const result = await sut.execute(params); + + expect(result).toEqual(expectedVariant); + }); +}); diff --git a/packages/core/src/usecase/CreateVariant.ts b/packages/core/src/usecase/CreateVariant.ts new file mode 100644 index 00000000..f3ae479d --- /dev/null +++ b/packages/core/src/usecase/CreateVariant.ts @@ -0,0 +1,41 @@ +import { GuildVariant } from "../domain/GuildVariant"; +import { VariantRepository } from "../repository/VariantRepository"; + +export type CreateVariantParams = { + guildId: string; + variantName: string; + displayName?: string; + hostname?: string; + defaultCfgs?: Record; + admins?: string[]; + image?: string; +} + +export class CreateVariant { + constructor(private readonly dependencies: { + variantRepository: VariantRepository; + }) {} + + async execute(params: CreateVariantParams): Promise { + const existingVariant = await this.dependencies.variantRepository.findByGuildIdAndName({ + guildId: params.guildId, + variantName: params.variantName, + }); + + if (existingVariant) { + throw new Error(`Variant ${params.variantName} already exists for this guild`); + } + + const variant: GuildVariant = { + guildId: params.guildId, + variantName: params.variantName, + displayName: params.displayName, + hostname: params.hostname, + defaultCfgs: params.defaultCfgs, + admins: params.admins, + image: params.image, + }; + + return await this.dependencies.variantRepository.create({ variant }); + } +} diff --git a/packages/core/src/usecase/DeleteVariant.test.ts b/packages/core/src/usecase/DeleteVariant.test.ts new file mode 100644 index 00000000..554ffe4d --- /dev/null +++ b/packages/core/src/usecase/DeleteVariant.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { DeleteVariant } from "./DeleteVariant"; +import { VariantRepository } from "../repository/VariantRepository"; +import { GuildVariant } from "../domain/GuildVariant"; + +describe("DeleteVariant", () => { + const makeSut = () => { + const variantRepository = mock(); + const sut = new DeleteVariant({ variantRepository }); + return { sut, variantRepository }; + }; + + it("should delete a variant when it exists", async () => { + const { sut, variantRepository } = makeSut(); + + const params = { + guildId: "guild123", + variantName: "variant-to-delete", + }; + + const existingVariant: GuildVariant = { + id: 1, + guildId: params.guildId, + variantName: params.variantName, + displayName: "Variant to Delete", + }; + + when(variantRepository.findByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(existingVariant); + + when(variantRepository.deleteByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(undefined); + + await sut.execute(params); + + expect(variantRepository.deleteByGuildIdAndName).toHaveBeenCalledWith({ + guildId: params.guildId, + variantName: params.variantName, + }); + }); + + it("should throw error when variant does not exist", async () => { + const { sut, variantRepository } = makeSut(); + + const params = { + guildId: "guild123", + variantName: "non-existent-variant", + }; + + when(variantRepository.findByGuildIdAndName) + .calledWith({ guildId: params.guildId, variantName: params.variantName }) + .mockResolvedValue(null); + + await expect(sut.execute(params)).rejects.toThrow( + `Variant ${params.variantName} does not exist for this guild` + ); + expect(variantRepository.deleteByGuildIdAndName).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/usecase/DeleteVariant.ts b/packages/core/src/usecase/DeleteVariant.ts new file mode 100644 index 00000000..81a5310a --- /dev/null +++ b/packages/core/src/usecase/DeleteVariant.ts @@ -0,0 +1,28 @@ +import { VariantRepository } from "../repository/VariantRepository"; + +export type DeleteVariantParams = { + guildId: string; + variantName: string; +} + +export class DeleteVariant { + constructor(private readonly dependencies: { + variantRepository: VariantRepository; + }) {} + + async execute(params: DeleteVariantParams): Promise { + const existingVariant = await this.dependencies.variantRepository.findByGuildIdAndName({ + guildId: params.guildId, + variantName: params.variantName, + }); + + if (!existingVariant) { + throw new Error(`Variant ${params.variantName} does not exist for this guild`); + } + + await this.dependencies.variantRepository.deleteByGuildIdAndName({ + guildId: params.guildId, + variantName: params.variantName, + }); + } +} diff --git a/packages/core/src/usecase/GetVariantsForGuild.test.ts b/packages/core/src/usecase/GetVariantsForGuild.test.ts new file mode 100644 index 00000000..f93ea624 --- /dev/null +++ b/packages/core/src/usecase/GetVariantsForGuild.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { GetVariantsForGuild } from "./GetVariantsForGuild"; +import { VariantRepository } from "../repository/VariantRepository"; +import { GuildVariant } from "../domain/GuildVariant"; + +describe("GetVariantsForGuild", () => { + const makeSut = () => { + const variantRepository = mock(); + const sut = new GetVariantsForGuild({ variantRepository }); + return { sut, variantRepository }; + }; + + it("should return all variants for a guild", async () => { + const { sut, variantRepository } = makeSut(); + + const guildId = "guild123"; + const expectedVariants: GuildVariant[] = [ + { + id: 1, + guildId, + variantName: "variant1", + displayName: "Variant 1", + }, + { + id: 2, + guildId, + variantName: "variant2", + displayName: "Variant 2", + }, + ]; + + when(variantRepository.findByGuildId) + .calledWith({ guildId }) + .mockResolvedValue(expectedVariants); + + const result = await sut.execute({ guildId }); + + expect(result).toEqual(expectedVariants); + expect(variantRepository.findByGuildId).toHaveBeenCalledWith({ guildId }); + }); + + it("should return empty array when guild has no variants", async () => { + const { sut, variantRepository } = makeSut(); + + const guildId = "guild123"; + + when(variantRepository.findByGuildId) + .calledWith({ guildId }) + .mockResolvedValue([]); + + const result = await sut.execute({ guildId }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/core/src/usecase/GetVariantsForGuild.ts b/packages/core/src/usecase/GetVariantsForGuild.ts new file mode 100644 index 00000000..1d8b7453 --- /dev/null +++ b/packages/core/src/usecase/GetVariantsForGuild.ts @@ -0,0 +1,18 @@ +import { GuildVariant } from "../domain/GuildVariant"; +import { VariantRepository } from "../repository/VariantRepository"; + +export type GetVariantsForGuildParams = { + guildId: string; +} + +export class GetVariantsForGuild { + constructor(private readonly dependencies: { + variantRepository: VariantRepository; + }) {} + + async execute(params: GetVariantsForGuildParams): Promise { + return await this.dependencies.variantRepository.findByGuildId({ + guildId: params.guildId, + }); + } +} diff --git a/packages/core/src/usecase/TerminateEmptyServers.ts b/packages/core/src/usecase/TerminateEmptyServers.ts index cd4cea44..551e7970 100644 --- a/packages/core/src/usecase/TerminateEmptyServers.ts +++ b/packages/core/src/usecase/TerminateEmptyServers.ts @@ -41,19 +41,32 @@ export class TerminateEmptyServers { }; }); + const variantConfigMap = new Map(); + for (const server of mergedServers) { + if (!variantConfigMap.has(server.variant)) { + try { + const variantConfig = await configManager.getVariantConfig({ + variant: server.variant, + guildId: server.guildId + }); + variantConfigMap.set(server.variant, variantConfig?.emptyMinutesTerminate ?? 10); + } catch (error) { + variantConfigMap.set(server.variant, 10); + } + } + } + const serversToDelete = mergedServers.filter((server) => { if (!server.emptySince) { return false; } - const variantConfig = configManager.getVariantConfig(server.variant); - const minutesEmpty = variantConfig?.emptyMinutesTerminate ?? 10; + const minutesEmpty = variantConfigMap.get(server.variant) ?? 10; const emptyDuration = new Date().getTime() - server.emptySince.getTime(); return emptyDuration >= minutesEmpty * 60 * 1000; }); for (const server of serversToDelete) { - const variantConfig = configManager.getVariantConfig(server.variant); - const minutesEmpty = variantConfig?.emptyMinutesTerminate ?? 10; + const minutesEmpty = variantConfigMap.get(server.variant) ?? 10; logger.emit({ severityText: 'INFO', diff --git a/packages/core/src/utils/ConfigManager.ts b/packages/core/src/utils/ConfigManager.ts index bef133a1..51a2f125 100644 --- a/packages/core/src/utils/ConfigManager.ts +++ b/packages/core/src/utils/ConfigManager.ts @@ -1,7 +1,7 @@ import { AWSConfig, CreditsConfig, DiscordConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "../domain"; export interface ConfigManager { - getVariantConfig(variant: Variant): VariantConfig; + getVariantConfig(params: { variant: Variant; guildId?: string }): Promise; getRegionConfig(region: Region): RegionConfig; getOracleConfig(): OracleConfig; getAWSConfig(): AWSConfig; diff --git a/packages/entrypoints/src/commands/CreateServer/handler.ts b/packages/entrypoints/src/commands/CreateServer/handler.ts index ba2de9d6..8fd5f01e 100644 --- a/packages/entrypoints/src/commands/CreateServer/handler.ts +++ b/packages/entrypoints/src/commands/CreateServer/handler.ts @@ -10,7 +10,7 @@ import { Collection, PermissionFlagsBits } from "discord.js"; -import { getRegionDisplayName, getVariantConfigs, getVariantConfig, Region } from "@tf2qs/core"; +import { getRegionDisplayName, Region, VariantRepository } from "@tf2qs/core"; import { CreateServerForUser } from "@tf2qs/core"; import { createInteractionStatusUpdater } from "@tf2qs/providers"; import { commandErrorHandler } from "../commandErrorHandler"; @@ -22,20 +22,14 @@ import { formatServerMessage } from "../formatServerMessage"; export function createServerCommandHandlerFactory(dependencies: { createServerForUser: CreateServerForUser, backgroundTaskQueue: BackgroundTaskQueue, + variantRepository: VariantRepository, }) { return async function createServerCommandHandler(interaction: ChatInputCommandInteraction) { - const { createServerForUser, backgroundTaskQueue } = dependencies; + const { createServerForUser, backgroundTaskQueue, variantRepository } = dependencies; const region = interaction.options.getString('region') as Region; // Step 1: Show variant buttons - const allVariants = getVariantConfigs(); - const guildSpecificVariants = allVariants.filter(variant => - variant.config.guildId === interaction.guildId - ); - - const variants = guildSpecificVariants.length > 0 - ? guildSpecificVariants - : allVariants.filter(variant => !variant.config.guildId); + const variants = await variantRepository.getVariantConfigs({ guildId: interaction.guildId || undefined }); const rows = []; for (let i = 0; i < variants.length; i += 5) { @@ -100,7 +94,7 @@ export function createServerCommandHandlerFactory(dependencies: { guildId: buttonInteraction.guildId!, statusUpdater: createInteractionStatusUpdater(buttonInteraction) }); - const variantConfig = getVariantConfig(variantName); + const variantConfig = await variantRepository.getVariantConfig({ variant: variantName, guildId: buttonInteraction.guildId || undefined }); if (variantConfig.managedExternally) { await buttonInteraction.followUp({ content: `🎉 **Server Created and Registered!** 🎉\n\n` + diff --git a/packages/entrypoints/src/commands/CreateVariant/definition.ts b/packages/entrypoints/src/commands/CreateVariant/definition.ts new file mode 100644 index 00000000..28495eb8 --- /dev/null +++ b/packages/entrypoints/src/commands/CreateVariant/definition.ts @@ -0,0 +1,43 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; + +export const createVariantCommandDefinition = new SlashCommandBuilder() + .setName('create-variant') + .setDescription('Creates a new server variant for this guild (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDMPermission(false) + .addStringOption(option => + option.setName('variant_name') + .setDescription('Unique name for the variant (no spaces)') + .setRequired(true) + ) + .addStringOption(option => + option.setName('display_name') + .setDescription('Display name shown to users') + .setRequired(true) + ) + .addStringOption(option => + option.setName('hostname') + .setDescription('Server hostname (use {region} for region placeholder)') + .setRequired(false) + ) + .addStringOption(option => + option.setName('default_cfgs') + .setDescription('JSON object of default configs (e.g., {"5cp": "config.cfg", "koth": "koth.cfg"})') + .setRequired(false) + ) + .addStringOption(option => + option.setName('admins') + .setDescription('Comma-separated list of Steam IDs (e.g., STEAM_0:1:12345,STEAM_0:0:67890)') + .setRequired(false) + ) + .addStringOption(option => + option.setName('image') + .setDescription('Docker image to use') + .setRequired(false) + .addChoices( + { name: 'Standard Competitive i386', value: 'sonikro/fat-tf2-standard-competitive-i386:latest' }, + { name: 'Standard Competitive AMD64', value: 'sonikro/fat-tf2-standard-competitive-amd64:latest' }, + { name: 'TF2 Pickup', value: 'sonikro/fat-tf2-pickup:latest' }, + { name: 'MGE', value: 'sonikro/fat-mge-tf:latest' } + ) + ); diff --git a/packages/entrypoints/src/commands/CreateVariant/handler.ts b/packages/entrypoints/src/commands/CreateVariant/handler.ts new file mode 100644 index 00000000..34b8787e --- /dev/null +++ b/packages/entrypoints/src/commands/CreateVariant/handler.ts @@ -0,0 +1,72 @@ +import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; +import { CreateVariant } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; + +export function createVariantCommandHandlerFactory(dependencies: { + createVariant: CreateVariant; +}) { + return async function createVariantCommandHandler(interaction: ChatInputCommandInteraction) { + const { createVariant } = dependencies; + + if (!interaction.guildId) { + await interaction.reply({ + content: 'This command can only be used in a guild.', + flags: MessageFlags.Ephemeral + }); + return; + } + + const variantName = interaction.options.getString('variant_name', true); + const displayName = interaction.options.getString('display_name', true); + const hostname = interaction.options.getString('hostname'); + const defaultCfgsStr = interaction.options.getString('default_cfgs'); + const adminsStr = interaction.options.getString('admins'); + const image = interaction.options.getString('image'); + + if (variantName.includes(' ')) { + await interaction.reply({ + content: 'Variant name cannot contain spaces.', + flags: MessageFlags.Ephemeral + }); + return; + } + + let defaultCfgs: Record | undefined; + if (defaultCfgsStr) { + try { + defaultCfgs = JSON.parse(defaultCfgsStr); + } catch (error) { + await interaction.reply({ + content: 'Invalid JSON format for default_cfgs. Example: {"5cp": "config.cfg", "koth": "koth.cfg"}', + flags: MessageFlags.Ephemeral + }); + return; + } + } + + const admins = adminsStr ? adminsStr.split(',').map(s => s.trim()) : undefined; + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + await createVariant.execute({ + guildId: interaction.guildId, + variantName, + displayName, + hostname: hostname || undefined, + defaultCfgs, + admins, + image: image || undefined, + }); + + await interaction.editReply({ + content: `✅ Variant **${displayName}** (${variantName}) has been created successfully!\n\n` + + `This variant is now available when creating servers in this guild.\n\n` + + `⚠️ Note: Custom variants currently use default server infrastructure settings. ` + + `Custom hostname, admins, and other settings will be applied in a future update.` + }); + } catch (error) { + await commandErrorHandler(interaction, error); + } + }; +} diff --git a/packages/entrypoints/src/commands/CreateVariant/index.ts b/packages/entrypoints/src/commands/CreateVariant/index.ts new file mode 100644 index 00000000..d62b70dd --- /dev/null +++ b/packages/entrypoints/src/commands/CreateVariant/index.ts @@ -0,0 +1,2 @@ +export { createVariantCommandDefinition } from "./definition"; +export { createVariantCommandHandlerFactory } from "./handler"; diff --git a/packages/entrypoints/src/commands/DeleteVariant/definition.ts b/packages/entrypoints/src/commands/DeleteVariant/definition.ts new file mode 100644 index 00000000..6fcf0301 --- /dev/null +++ b/packages/entrypoints/src/commands/DeleteVariant/definition.ts @@ -0,0 +1,12 @@ +import { PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; + +export const deleteVariantCommandDefinition = new SlashCommandBuilder() + .setName('delete-variant') + .setDescription('Deletes a server variant for this guild (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDMPermission(false) + .addStringOption(option => + option.setName('variant_name') + .setDescription('Name of the variant to delete') + .setRequired(true) + ); diff --git a/packages/entrypoints/src/commands/DeleteVariant/handler.ts b/packages/entrypoints/src/commands/DeleteVariant/handler.ts new file mode 100644 index 00000000..21a26fae --- /dev/null +++ b/packages/entrypoints/src/commands/DeleteVariant/handler.ts @@ -0,0 +1,36 @@ +import { ChatInputCommandInteraction, MessageFlags } from "discord.js"; +import { DeleteVariant } from "@tf2qs/core"; +import { commandErrorHandler } from "../commandErrorHandler"; + +export function deleteVariantCommandHandlerFactory(dependencies: { + deleteVariant: DeleteVariant; +}) { + return async function deleteVariantCommandHandler(interaction: ChatInputCommandInteraction) { + const { deleteVariant } = dependencies; + + if (!interaction.guildId) { + await interaction.reply({ + content: 'This command can only be used in a guild.', + flags: MessageFlags.Ephemeral + }); + return; + } + + const variantName = interaction.options.getString('variant_name', true); + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + await deleteVariant.execute({ + guildId: interaction.guildId, + variantName, + }); + + await interaction.editReply({ + content: `✅ Variant **${variantName}** has been deleted successfully!` + }); + } catch (error) { + await commandErrorHandler(interaction, error); + } + }; +} diff --git a/packages/entrypoints/src/commands/DeleteVariant/index.ts b/packages/entrypoints/src/commands/DeleteVariant/index.ts new file mode 100644 index 00000000..9bb2995b --- /dev/null +++ b/packages/entrypoints/src/commands/DeleteVariant/index.ts @@ -0,0 +1,2 @@ +export { deleteVariantCommandDefinition } from "./definition"; +export { deleteVariantCommandHandlerFactory } from "./handler"; diff --git a/packages/entrypoints/src/commands/index.ts b/packages/entrypoints/src/commands/index.ts index 938d3cb8..293f569c 100644 --- a/packages/entrypoints/src/commands/index.ts +++ b/packages/entrypoints/src/commands/index.ts @@ -14,6 +14,9 @@ import { ConfigManager } from "@tf2qs/core"; import { setUserDataDefinition, setUserDataHandlerFactory } from "./SetUserData"; import { SetUserData } from "@tf2qs/core"; import { statusCommandDefinition, createStatusCommandHandlerFactory } from "./Status"; +import { createVariantCommandDefinition, createVariantCommandHandlerFactory } from "./CreateVariant"; +import { deleteVariantCommandDefinition, deleteVariantCommandHandlerFactory } from "./DeleteVariant"; +import { CreateVariant, DeleteVariant, VariantRepository } from "@tf2qs/core"; export type CommandDependencies = { createServerForUser: CreateServerForUser; @@ -24,6 +27,9 @@ export type CommandDependencies = { backgroundTaskQueue: BackgroundTaskQueue; getServerStatus: GetServerStatus; getUserServers: GetUserServers; + createVariant: CreateVariant; + deleteVariant: DeleteVariant; + variantRepository: VariantRepository; } export function createCommands(dependencies: CommandDependencies) { @@ -34,6 +40,7 @@ export function createCommands(dependencies: CommandDependencies) { handler: createServerCommandHandlerFactory({ createServerForUser: dependencies.createServerForUser, backgroundTaskQueue: dependencies.backgroundTaskQueue, + variantRepository: dependencies.variantRepository, }), }, terminateServer: { @@ -64,6 +71,20 @@ export function createCommands(dependencies: CommandDependencies) { getUserServers: dependencies.getUserServers, }) }, + createVariant: { + name: "create-variant", + definition: createVariantCommandDefinition, + handler: createVariantCommandHandlerFactory({ + createVariant: dependencies.createVariant, + }) + }, + deleteVariant: { + name: "delete-variant", + definition: deleteVariantCommandDefinition, + handler: deleteVariantCommandHandlerFactory({ + deleteVariant: dependencies.deleteVariant, + }) + }, ...(dependencies.configManager.getCreditsConfig().enabled ? { getBalance: { name: "get-balance", diff --git a/packages/entrypoints/src/discordBot.ts b/packages/entrypoints/src/discordBot.ts index 5ed09ec2..42482098 100644 --- a/packages/entrypoints/src/discordBot.ts +++ b/packages/entrypoints/src/discordBot.ts @@ -2,8 +2,10 @@ import { ChatInputCommandInteraction, Client, GatewayIntentBits, MessageFlags, R import { ConsumeCreditsFromRunningServers } from "@tf2qs/core"; import { CreateCreditsPurchaseOrder } from "@tf2qs/core"; import { CreateServerForUser } from "@tf2qs/core"; +import { CreateVariant } from "@tf2qs/core"; import { DeleteServer } from "@tf2qs/core"; import { DeleteServerForUser } from "@tf2qs/core"; +import { DeleteVariant } from "@tf2qs/core"; import { GenerateMonthlyUsageReport } from "@tf2qs/core"; import { GetServerStatus } from "@tf2qs/core"; import { GetUserServers } from "@tf2qs/core"; @@ -27,6 +29,7 @@ import { SQliteServerActivityRepository } from "@tf2qs/providers"; import { SQLiteServerRepository } from "@tf2qs/providers"; import { SQliteUserCreditsRepository } from "@tf2qs/providers"; import { SQliteUserRepository } from "@tf2qs/providers"; +import { SQliteVariantRepository } from "@tf2qs/providers"; import { AdyenPaymentService } from "@tf2qs/providers"; import { ChancePasswordGeneratorService } from "@tf2qs/providers"; import { defaultAWSServiceFactory } from "@tf2qs/providers"; @@ -38,7 +41,7 @@ import { FileSystemOCICredentialsFactory } from "@tf2qs/providers"; import { PaypalPaymentService } from "@tf2qs/providers"; import { RCONServerCommander } from "@tf2qs/providers"; import { DefaultServerManagerFactory } from "@tf2qs/providers"; -import { defaultConfigManager } from "@tf2qs/providers"; +import { DefaultConfigManager } from "@tf2qs/providers"; import { logger } from "@tf2qs/telemetry"; import { createCommands } from "./commands"; import { initializeExpress } from "./http/express"; @@ -72,7 +75,7 @@ export async function startDiscordBot() { const eventLogger = new DiscordEventLogger({ discordClient: client, - configManager: defaultConfigManager, + configManager: configManager, }) const serverAbortManager = new DefaultServerAbortManager(); @@ -81,7 +84,7 @@ export async function startDiscordBot() { const serverManagerFactory = new DefaultServerManagerFactory({ serverCommander, - configManager: defaultConfigManager, + configManager: configManager, passwordGeneratorService: passwordGeneratorService, serverAbortManager, ociCredentialsFactory: FileSystemOCICredentialsFactory @@ -123,6 +126,14 @@ export async function startDiscordBot() { const userBanRepository = new CsvUserBanRepository() + const variantRepository = new SQliteVariantRepository({ + knex: KnexConnectionManager.client + }) + + const configManager = new DefaultConfigManager({ + variantRepository + }) + const backgroundTaskQueue = new InMemoryBackgroundTaskQueue(defaultGracefulShutdownManager); const deleteServerUseCase = new DeleteServerForUser({ serverManagerFactory: serverManagerFactory, @@ -156,7 +167,7 @@ export async function startDiscordBot() { serverRepository, userCreditsRepository, eventLogger, - configManager: defaultConfigManager, + configManager: configManager, userRepository, guildParametersRepository, userBanRepository @@ -175,8 +186,15 @@ export async function startDiscordBot() { getUserServers: new GetUserServers({ serverRepository }), + createVariant: new CreateVariant({ + variantRepository + }), + deleteVariant: new DeleteVariant({ + variantRepository + }), + variantRepository, userCreditsRepository, - configManager: defaultConfigManager, + configManager: configManager, backgroundTaskQueue }) @@ -187,7 +205,7 @@ export async function startDiscordBot() { serverActivityRepository: serverActivityRepository, serverCommander: serverCommander, eventLogger, - configManager: defaultConfigManager, + configManager: configManager, backgroundTaskQueue }), eventLogger @@ -198,7 +216,7 @@ export async function startDiscordBot() { serverRepository, userCreditsRepository, }), - configManager: defaultConfigManager, + configManager: configManager, eventLogger }) @@ -210,7 +228,7 @@ export async function startDiscordBot() { serverCommander, eventLogger }), - configManager: defaultConfigManager, + configManager: configManager, eventLogger }) @@ -240,7 +258,7 @@ export async function startDiscordBot() { const oracleCostProvider = new OracleCostProvider({ ociClientFactory: defaultOracleServiceFactory, - configManager: defaultConfigManager, + configManager: configManager, }) const awsCostProvider = new AWSCostProvider({ @@ -257,7 +275,7 @@ export async function startDiscordBot() { reportRepository, costProvider: defaultCostProvider, }), - configManager: defaultConfigManager, + configManager: configManager, eventLogger, discordClient: client, }) diff --git a/packages/providers/src/cloud-providers/aws/AWSServerManager.ts b/packages/providers/src/cloud-providers/aws/AWSServerManager.ts index 1f686522..1091e1f5 100644 --- a/packages/providers/src/cloud-providers/aws/AWSServerManager.ts +++ b/packages/providers/src/cloud-providers/aws/AWSServerManager.ts @@ -101,6 +101,7 @@ export class AWSServerManager implements ServerManager { variantName: args.variantName, statusUpdater: args.statusUpdater, sourcemodAdminSteamId: args.sourcemodAdminSteamId, + guildId: args.guildId, extraEnvs: args.extraEnvs, }); @@ -115,7 +116,10 @@ export class AWSServerManager implements ServerManager { const credentials = ServerCredentials.generate(this.passwordGeneratorService); // Get configuration - const variantConfig = this.configManager.getVariantConfig(args.variantName); + const variantConfig = await this.configManager.getVariantConfig({ + variant: args.variantName, + guildId: args.guildId + }); const regionConfig = this.configManager.getRegionConfig(args.region); // Build environment variables diff --git a/packages/providers/src/cloud-providers/aws/services/DefaultTaskDefinitionService.ts b/packages/providers/src/cloud-providers/aws/services/DefaultTaskDefinitionService.ts index bc53d91b..2c9ec090 100644 --- a/packages/providers/src/cloud-providers/aws/services/DefaultTaskDefinitionService.ts +++ b/packages/providers/src/cloud-providers/aws/services/DefaultTaskDefinitionService.ts @@ -35,7 +35,10 @@ export class DefaultTaskDefinitionService implements TaskDefinitionServiceInterf async () => { const awsRegionConfig = this.awsConfigService.getRegionConfig(context.region); const { ecsClient } = this.awsConfigService.getClients(context.region); - const variantConfig = this.configManager.getVariantConfig(context.variantName); + const variantConfig = await this.configManager.getVariantConfig({ + variant: context.variantName, + guildId: context.guildId + }); this.tracingService.logOperationStart('Registering task definition', context.serverId, context.region); diff --git a/packages/providers/src/cloud-providers/oracle/OCIServerManager.ts b/packages/providers/src/cloud-providers/oracle/OCIServerManager.ts index 032bdbdc..d67aff16 100644 --- a/packages/providers/src/cloud-providers/oracle/OCIServerManager.ts +++ b/packages/providers/src/cloud-providers/oracle/OCIServerManager.ts @@ -75,18 +75,22 @@ export class OCIServerManager implements ServerManager { variantName: Variant; statusUpdater: StatusUpdater; sourcemodAdminSteamId?: string; + guildId?: string; extraEnvs?: Record; }): Promise { return await tracer.startActiveSpan('OCIServerManager.deployServer', async (parentSpan: Span) => { parentSpan.setAttribute('serverId', args.serverId); const startTime = Date.now(); const { serverCommander, configManager, passwordGeneratorService, ociClientFactory, serverAbortManager } = this.dependencies; - const { region, variantName, sourcemodAdminSteamId, serverId, extraEnvs = {}, statusUpdater } = args; + const { region, variantName, sourcemodAdminSteamId, serverId, guildId, extraEnvs = {}, statusUpdater } = args; const abortController = serverAbortManager.getOrCreate(serverId); try { const { containerClient, vncClient } = ociClientFactory(region); - const variantConfig = configManager.getVariantConfig(variantName); + const variantConfig = await configManager.getVariantConfig({ + variant: variantName, + guildId + }); const regionConfig = configManager.getRegionConfig(region); const oracleConfig = configManager.getOracleConfig(); const oracleRegionConfig = oracleConfig.regions[region]; diff --git a/packages/providers/src/cloud-providers/oracle/OracleVMManager.ts b/packages/providers/src/cloud-providers/oracle/OracleVMManager.ts index bfe2aeda..5c4fee1b 100644 --- a/packages/providers/src/cloud-providers/oracle/OracleVMManager.ts +++ b/packages/providers/src/cloud-providers/oracle/OracleVMManager.ts @@ -75,17 +75,21 @@ export class OracleVMManager implements ServerManager { variantName: Variant; statusUpdater: StatusUpdater; sourcemodAdminSteamId?: string; + guildId?: string; extraEnvs?: Record; }): Promise { return await tracer.startActiveSpan('OracleVMManager.deployServer', async (parentSpan: Span) => { parentSpan.setAttribute('serverId', args.serverId); const startTime = Date.now(); const { configManager, passwordGeneratorService, serverAbortManager } = this.dependencies; - const { region, variantName, sourcemodAdminSteamId, serverId, extraEnvs = {}, statusUpdater } = args; + const { region, variantName, sourcemodAdminSteamId, serverId, guildId, extraEnvs = {}, statusUpdater } = args; const abortController = serverAbortManager.getOrCreate(serverId); try { - const variantConfig = configManager.getVariantConfig(variantName); + const variantConfig = await configManager.getVariantConfig({ + variant: variantName, + guildId + }); const regionConfig = configManager.getRegionConfig(region); const oracleConfig = configManager.getOracleConfig(); const oracleRegionConfig = oracleConfig.regions[region]; diff --git a/packages/providers/src/repository/SQliteServerRepository.ts b/packages/providers/src/repository/SQliteServerRepository.ts index 189a4765..8c7b45c3 100644 --- a/packages/providers/src/repository/SQliteServerRepository.ts +++ b/packages/providers/src/repository/SQliteServerRepository.ts @@ -22,9 +22,10 @@ export class SQLiteServerRepository implements ServerRepository { tvPassword: server.tvPassword, createdAt: server.createdAt ?? new Date(), createdBy: server.createdBy, + guild_id: server.guildId, status: server.status, sv_logsecret: server.logSecret - } as Server) + } as any) .onConflict('serverId') .merge(); @@ -97,6 +98,7 @@ export class SQLiteServerRepository implements ServerRepository { return { ...server, createdAt: toDate(server.createdAt), + guildId: server.guild_id, logSecret: server.sv_logsecret }; } diff --git a/packages/providers/src/repository/SQliteVariantRepository.test.ts b/packages/providers/src/repository/SQliteVariantRepository.test.ts new file mode 100644 index 00000000..c499ad4b --- /dev/null +++ b/packages/providers/src/repository/SQliteVariantRepository.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mock } from "vitest-mock-extended"; +import { when } from "vitest-when"; +import { Knex } from "knex"; +import { SQliteVariantRepository } from "./SQliteVariantRepository"; +import { GuildVariant } from "@tf2qs/core"; + +describe("SQliteVariantRepository", () => { + const makeSut = () => { + const knex = mock(); + const queryBuilder = mock(); + + when(knex).calledWith("guild_variants").mockReturnValue(queryBuilder as any); + + const sut = new SQliteVariantRepository({ knex }); + return { sut, knex, queryBuilder }; + }; + + describe("create", () => { + it("should create a new variant and return it with id", async () => { + const { sut, queryBuilder } = makeSut(); + + const variant: GuildVariant = { + guildId: "guild123", + variantName: "test-variant", + displayName: "Test Variant", + hostname: "Test Server", + defaultCfgs: { "5cp": "test.cfg" }, + admins: ["STEAM_0:1:12345"], + image: "test/image:latest", + emptyMinutesTerminate: 15, + }; + + when(queryBuilder.insert) + .calledWith(expect.objectContaining({ + guild_id: variant.guildId, + variant_name: variant.variantName, + })) + .mockResolvedValue([1] as any); + + const result = await sut.create({ variant }); + + expect(result).toEqual({ ...variant, id: 1 }); + }); + }); + + describe("findByGuildIdAndName", () => { + it("should return variant when it exists", async () => { + const { sut, queryBuilder } = makeSut(); + + const dbRow = { + id: 1, + guild_id: "guild123", + variant_name: "test-variant", + display_name: "Test Variant", + hostname: "Test Server", + default_cfgs: JSON.stringify({ "5cp": "test.cfg" }), + admins: JSON.stringify(["STEAM_0:1:12345"]), + image: "test/image:latest", + empty_minutes_terminate: 15, + created_at: new Date(), + updated_at: new Date(), + }; + + when(queryBuilder.where) + .calledWith({ guild_id: "guild123", variant_name: "test-variant" }) + .mockReturnValue(queryBuilder as any); + + when(queryBuilder.first) + .calledWith() + .mockResolvedValue(dbRow as any); + + const result = await sut.findByGuildIdAndName({ + guildId: "guild123", + variantName: "test-variant", + }); + + expect(result).toEqual({ + id: dbRow.id, + guildId: dbRow.guild_id, + variantName: dbRow.variant_name, + displayName: dbRow.display_name, + hostname: dbRow.hostname, + defaultCfgs: { "5cp": "test.cfg" }, + admins: ["STEAM_0:1:12345"], + image: dbRow.image, + emptyMinutesTerminate: dbRow.empty_minutes_terminate, + createdAt: dbRow.created_at, + updatedAt: dbRow.updated_at, + }); + }); + + it("should return null when variant does not exist", async () => { + const { sut, queryBuilder } = makeSut(); + + when(queryBuilder.where) + .calledWith({ guild_id: "guild123", variant_name: "non-existent" }) + .mockReturnValue(queryBuilder as any); + + when(queryBuilder.first) + .calledWith() + .mockResolvedValue(undefined as any); + + const result = await sut.findByGuildIdAndName({ + guildId: "guild123", + variantName: "non-existent", + }); + + expect(result).toBeNull(); + }); + }); + + describe("findByGuildId", () => { + it("should return all variants for a guild", async () => { + const { sut, queryBuilder } = makeSut(); + + const dbRows = [ + { + id: 1, + guild_id: "guild123", + variant_name: "variant1", + display_name: "Variant 1", + hostname: null, + default_cfgs: null, + admins: null, + image: null, + empty_minutes_terminate: null, + created_at: new Date(), + updated_at: new Date(), + }, + { + id: 2, + guild_id: "guild123", + variant_name: "variant2", + display_name: "Variant 2", + hostname: null, + default_cfgs: null, + admins: null, + image: null, + empty_minutes_terminate: null, + created_at: new Date(), + updated_at: new Date(), + }, + ]; + + when(queryBuilder.where) + .calledWith({ guild_id: "guild123" }) + .mockResolvedValue(dbRows as any); + + const result = await sut.findByGuildId({ guildId: "guild123" }); + + expect(result).toHaveLength(2); + expect(result[0].variantName).toBe("variant1"); + expect(result[1].variantName).toBe("variant2"); + }); + + it("should return empty array when guild has no variants", async () => { + const { sut, queryBuilder } = makeSut(); + + when(queryBuilder.where) + .calledWith({ guild_id: "guild123" }) + .mockResolvedValue([] as any); + + const result = await sut.findByGuildId({ guildId: "guild123" }); + + expect(result).toEqual([]); + }); + }); + + describe("deleteByGuildIdAndName", () => { + it("should delete a variant", async () => { + const { sut, queryBuilder } = makeSut(); + + when(queryBuilder.where) + .calledWith({ guild_id: "guild123", variant_name: "variant-to-delete" }) + .mockReturnValue(queryBuilder as any); + + when(queryBuilder.delete) + .calledWith() + .mockResolvedValue(1 as any); + + await sut.deleteByGuildIdAndName({ + guildId: "guild123", + variantName: "variant-to-delete", + }); + + expect(queryBuilder.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/providers/src/repository/SQliteVariantRepository.ts b/packages/providers/src/repository/SQliteVariantRepository.ts new file mode 100644 index 00000000..d1570452 --- /dev/null +++ b/packages/providers/src/repository/SQliteVariantRepository.ts @@ -0,0 +1,175 @@ +import { Knex } from "knex"; +import { GuildVariant, VariantRepository, Variant, VariantConfig, VariantWithConfig } from "@tf2qs/core"; +import config from "config"; + +export class SQliteVariantRepository implements VariantRepository { + constructor(private readonly dependencies: { knex: Knex }) {} + + async create(params: { variant: GuildVariant }): Promise { + const { variant } = params; + + const [id] = await this.dependencies.knex("guild_variants").insert({ + guild_id: variant.guildId, + variant_name: variant.variantName, + display_name: variant.displayName, + hostname: variant.hostname, + default_cfgs: variant.defaultCfgs ? JSON.stringify(variant.defaultCfgs) : null, + admins: variant.admins ? JSON.stringify(variant.admins) : null, + image: variant.image, + }); + + return { + ...variant, + id, + }; + } + + async findByGuildIdAndName(params: { guildId: string; variantName: string }): Promise { + const { guildId, variantName } = params; + + const result = await this.dependencies.knex("guild_variants") + .where({ guild_id: guildId, variant_name: variantName }) + .first(); + + if (!result) return null; + + return this.mapToGuildVariant(result); + } + + async findByGuildId(params: { guildId: string }): Promise { + const { guildId } = params; + + const results = await this.dependencies.knex("guild_variants") + .where({ guild_id: guildId }); + + return results.map(this.mapToGuildVariant); + } + + async deleteByGuildIdAndName(params: { guildId: string; variantName: string }): Promise { + const { guildId, variantName } = params; + + await this.dependencies.knex("guild_variants") + .where({ guild_id: guildId, variant_name: variantName }) + .delete(); + } + + async findAll(): Promise { + const results = await this.dependencies.knex("guild_variants").select('*'); + return results.map(this.mapToGuildVariant); + } + + async getVariantConfig(params: { variant: Variant; guildId?: string }): Promise { + const { variant, guildId } = params; + + if (guildId) { + const guildVariant = await this.findByGuildIdAndName({ + guildId, + variantName: variant, + }); + + if (guildVariant) { + return this.mapGuildVariantToVariantConfig(guildVariant); + } + } + + const defaultConfig = this.getDefaultVariantConfig(); + try { + const variantConfig = config.get>(`variants.${variant}`); + return { + ...defaultConfig, + ...variantConfig, + serverName: variant, + }; + } catch (error) { + return defaultConfig; + } + } + + async getVariantConfigs(params: { guildId?: string }): Promise { + const { guildId } = params; + + if (!guildId) { + const variants = config.get>>(`variants`); + const defaultConfig = this.getDefaultVariantConfig(); + return Object.keys(variants) + .filter(it => it !== "default") + .map(variant => ({ + name: variant, + config: { + ...defaultConfig, + ...variants[variant], + serverName: variant, + }, + })); + } + + const guildVariants = await this.findByGuildId({ guildId }); + const guildVariantConfigs = guildVariants.map(gv => ({ + name: gv.variantName, + config: this.mapGuildVariantToVariantConfig(gv), + })); + + const variants = config.get>>(`variants`); + const configVariants = Object.keys(variants) + .filter(it => it !== "default") + .map(variant => { + const variantConfig = variants[variant]; + return { + name: variant, + config: { + ...this.getDefaultVariantConfig(), + ...variantConfig, + serverName: variant, + }, + guildId: variantConfig.guildId, + }; + }); + + const guildSpecificConfigVariants = configVariants.filter(v => v.guildId === guildId); + const defaultConfigVariants = configVariants.filter(v => !v.guildId); + + if (guildVariantConfigs.length > 0 || guildSpecificConfigVariants.length > 0) { + return [...guildVariantConfigs, ...guildSpecificConfigVariants.map(v => ({ name: v.name, config: v.config }))]; + } + + return defaultConfigVariants.map(v => ({ name: v.name, config: v.config })); + } + + private getDefaultVariantConfig(): VariantConfig { + const defaultSettings = config.get(`variants.default`); + return { + ...defaultSettings, + serverName: "default", + }; + } + + private mapGuildVariantToVariantConfig(guildVariant: GuildVariant): VariantConfig { + const defaultConfig = this.getDefaultVariantConfig(); + + return { + ...defaultConfig, + displayName: guildVariant.displayName, + hostname: guildVariant.hostname, + defaultCfgs: guildVariant.defaultCfgs, + admins: guildVariant.admins, + image: guildVariant.image || defaultConfig.image, + guildId: guildVariant.guildId, + serverName: guildVariant.variantName, + }; + } + + private mapToGuildVariant(row: any): GuildVariant { + return { + id: row.id, + guildId: row.guild_id, + variantName: row.variant_name, + displayName: row.display_name, + hostname: row.hostname, + defaultCfgs: row.default_cfgs ? JSON.parse(row.default_cfgs) : undefined, + admins: row.admins ? JSON.parse(row.admins) : undefined, + image: row.image, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } +} diff --git a/packages/providers/src/repository/index.ts b/packages/providers/src/repository/index.ts index ad2aaf00..d9a2166c 100644 --- a/packages/providers/src/repository/index.ts +++ b/packages/providers/src/repository/index.ts @@ -7,3 +7,4 @@ export * from './SQliteServerActivityRepository'; export * from './SQliteServerRepository'; export * from './SQliteUserCreditsRepository'; export * from './SQliteUserRepository'; +export * from './SQliteVariantRepository'; diff --git a/packages/providers/src/utils/DefaultConfigManager.ts b/packages/providers/src/utils/DefaultConfigManager.ts index 0c898b9c..cf0b66c5 100644 --- a/packages/providers/src/utils/DefaultConfigManager.ts +++ b/packages/providers/src/utils/DefaultConfigManager.ts @@ -1,10 +1,15 @@ -import { AWSConfig, CreditsConfig, DiscordConfig, getAWSConfig, getCreditsConfig, getDiscordConfig, getOracleConfig, getRegionConfig, getVariantConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig } from "@tf2qs/core"; +import { AWSConfig, CreditsConfig, DiscordConfig, getAWSConfig, getCreditsConfig, getDiscordConfig, getOracleConfig, getRegionConfig, OracleConfig, Region, RegionConfig, Variant, VariantConfig, VariantRepository } from "@tf2qs/core"; import { ConfigManager } from "@tf2qs/core"; export class DefaultConfigManager implements ConfigManager { - getVariantConfig(variant: Variant): VariantConfig { - return getVariantConfig(variant) + constructor(private readonly dependencies: { + variantRepository: VariantRepository; + }) {} + + async getVariantConfig(params: { variant: Variant; guildId?: string }): Promise { + return await this.dependencies.variantRepository.getVariantConfig(params); } + getRegionConfig(region: Region): RegionConfig { return getRegionConfig(region) } @@ -20,5 +25,4 @@ export class DefaultConfigManager implements ConfigManager { getCreditsConfig(): CreditsConfig { return getCreditsConfig(); } -} -export const defaultConfigManager = new DefaultConfigManager(); \ No newline at end of file +} \ No newline at end of file