Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
407 changes: 82 additions & 325 deletions config/default.json

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions migrations/20251215020719_guild_variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.dropTableIfExists("guild_variants");
}
14 changes: 14 additions & 0 deletions migrations/20251215021726_add_guildid_to_servers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Knex } from "knex";

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable("servers", (table) => {
table.string("guild_id").nullable();
table.index("guild_id");
});
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable("servers", (table) => {
table.dropColumn("guild_id");
});
}
29 changes: 29 additions & 0 deletions migrations/20251215032644_populate_variants_from_config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Knex } from "knex";
import config from "config";

export async function up(knex: Knex): Promise<void> {
const variants = config.get<Record<string, any>>('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<void> {
await knex('guild_variants').del();
}
4 changes: 4 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/DeployedServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Server {
tvPassword?: string;
createdAt?: Date;
createdBy?: string;
guildId?: string;
status?: ServerStatus;
logSecret?: number;
}
12 changes: 12 additions & 0 deletions packages/core/src/domain/GuildVariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type GuildVariant = {
id?: number;
guildId: string;
variantName: string;
displayName?: string;
hostname?: string;
defaultCfgs?: Record<string, string>;
admins?: string[];
image?: string;
createdAt?: Date;
updatedAt?: Date;
}
19 changes: 0 additions & 19 deletions packages/core/src/domain/Variant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import config from "config";

export type Variant = string;

export type VariantConfig = {
Expand Down Expand Up @@ -43,21 +41,4 @@ export type VariantConfig = {
* @default false
*/
managedExternally?: boolean;
}

export function getVariantConfig(variant: Variant) {
const defaultSettings = config.get<VariantConfig>(`variants.default`);
const variantConfig = config.get<VariantConfig>(`variants.${variant}`); // This will throw if the variant is not found
return {
...defaultSettings,
...variantConfig,
};
}

export function getVariantConfigs() {
const variants = config.get<Record<string, VariantConfig>>(`variants`);
return Object.keys(variants).filter(it => it !== "default").map(variant => ({
name: variant,
config: getVariantConfig(variant),
}));
}
1 change: 1 addition & 0 deletions packages/core/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/models/DeploymentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,23 @@ export class DeploymentContext {
variantName,
statusUpdater,
sourcemodAdminSteamId,
guildId,
extraEnvs = {}
}: {
serverId: string;
region: Region;
variantName: Variant;
statusUpdater: StatusUpdater;
sourcemodAdminSteamId?: string;
guildId?: string;
extraEnvs?: Record<string, string>;
}) {
this.serverId = serverId;
this.region = region;
this.variantName = variantName;
this.statusUpdater = statusUpdater;
this.sourcemodAdminSteamId = sourcemodAdminSteamId;
this.guildId = guildId;
this.extraEnvs = extraEnvs;
}

Expand All @@ -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<string, string>;

/**
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/repository/VariantRepository.ts
Original file line number Diff line number Diff line change
@@ -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<GuildVariant>;
findByGuildIdAndName(params: { guildId: string; variantName: string }): Promise<GuildVariant | null>;
findByGuildId(params: { guildId: string }): Promise<GuildVariant[]>;
deleteByGuildIdAndName(params: { guildId: string; variantName: string }): Promise<void>;
getVariantConfig(params: { variant: Variant; guildId?: string }): Promise<VariantConfig>;
getVariantConfigs(params: { guildId?: string }): Promise<VariantWithConfig[]>;
findAll(): Promise<GuildVariant[]>;
}
1 change: 1 addition & 0 deletions packages/core/src/services/ServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ServerManager {
variantName: Variant,
statusUpdater: StatusUpdater,
sourcemodAdminSteamId?: string,
guildId?: string,
extraEnvs?: Record<string, string>,
}): Promise<Server>;

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/usecase/CreateServerForUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class CreateServerForUser {
region: args.region,
variant: args.variantName,
createdBy: args.creatorId,
guildId: args.guildId,
status: "pending",
} as Server, trx);
});
Expand All @@ -122,6 +123,7 @@ export class CreateServerForUser {
variantName: args.variantName,
sourcemodAdminSteamId: user.steamIdText,
serverId,
guildId: args.guildId,
extraEnvs: guildParameters?.environment_variables || {},
statusUpdater
});
Expand All @@ -134,6 +136,7 @@ export class CreateServerForUser {
await serverRepository.upsertServer({
...server,
createdBy: args.creatorId,
guildId: args.guildId,
status: "ready"
});
return server;
Expand Down
115 changes: 115 additions & 0 deletions packages/core/src/usecase/CreateVariant.test.ts
Original file line number Diff line number Diff line change
@@ -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<VariantRepository>();
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);
});
});
41 changes: 41 additions & 0 deletions packages/core/src/usecase/CreateVariant.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
admins?: string[];
image?: string;
}

export class CreateVariant {
constructor(private readonly dependencies: {
variantRepository: VariantRepository;
}) {}

async execute(params: CreateVariantParams): Promise<GuildVariant> {
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 });
}
}
Loading