Skip to content
Merged
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
2 changes: 0 additions & 2 deletions .github/agents/quickserver-contributor.agent.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
---
description: 'Quickserver Contributor'
tools: ['runCommands', 'runTasks', 'edit', 'runNotebooks', 'search', 'new', 'github/*', 'extensions', 'todos', 'runTests', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'openSimpleBrowser', 'fetch', 'githubRepo']
model: Claude Haiku 4.5 (Preview) (copilot)
---
You are a Senior Staff Software Engineer, you write code that follows the following principles:

Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/models/DeploymentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("DeploymentContext", () => {
const region = chance.pickone(validRegions);
const variantName = chance.pickone(["standard-competitive", "casual", "vanilla"]) as Variant;
const serverId = chance.guid();
const firstMap = chance.pickone(['cp_process_f12', 'koth_product_final']);
const sourcemodAdminSteamId = chance.string({ length: 17, pool: '0123456789' });
const extraEnvs = {
TEST_ENV: chance.word(),
Expand All @@ -28,6 +29,7 @@ describe("DeploymentContext", () => {
region,
variantName,
serverId,
firstMap,
sourcemodAdminSteamId,
extraEnvs
}
Expand All @@ -42,6 +44,7 @@ describe("DeploymentContext", () => {
serverId: data.serverId,
region: data.region,
variantName: data.variantName,
firstMap: data.firstMap,
statusUpdater: mocks.statusUpdater,
sourcemodAdminSteamId: data.sourcemodAdminSteamId,
extraEnvs: data.extraEnvs
Expand All @@ -50,6 +53,7 @@ describe("DeploymentContext", () => {
expect(context.serverId).toBe(data.serverId);
expect(context.region).toBe(data.region);
expect(context.variantName).toBe(data.variantName);
expect(context.firstMap).toBe(data.firstMap);
expect(context.statusUpdater).toBe(mocks.statusUpdater);
expect(context.sourcemodAdminSteamId).toBe(data.sourcemodAdminSteamId);
expect(context.extraEnvs).toEqual(data.extraEnvs);
Expand All @@ -68,6 +72,7 @@ describe("DeploymentContext", () => {
expect(context.serverId).toBe(data.serverId);
expect(context.region).toBe(data.region);
expect(context.variantName).toBe(data.variantName);
expect(context.firstMap).toBeUndefined();
expect(context.statusUpdater).toBe(mocks.statusUpdater);
expect(context.sourcemodAdminSteamId).toBeUndefined();
expect(context.extraEnvs).toEqual({});
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 @@ -9,20 +9,23 @@ export class DeploymentContext {
serverId,
region,
variantName,
firstMap,
statusUpdater,
sourcemodAdminSteamId,
extraEnvs = {}
}: {
serverId: string;
region: Region;
variantName: Variant;
firstMap?: string;
statusUpdater: StatusUpdater;
sourcemodAdminSteamId?: string;
extraEnvs?: Record<string, string>;
}) {
this.serverId = serverId;
this.region = region;
this.variantName = variantName;
this.firstMap = firstMap;
this.statusUpdater = statusUpdater;
this.sourcemodAdminSteamId = sourcemodAdminSteamId;
this.extraEnvs = extraEnvs;
Expand All @@ -31,6 +34,7 @@ export class DeploymentContext {
public readonly serverId: string;
public readonly region: Region;
public readonly variantName: Variant;
public readonly firstMap?: string;
public readonly statusUpdater: StatusUpdater;
public readonly sourcemodAdminSteamId?: string;
public readonly extraEnvs: Record<string, string>;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/services/ServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ServerManager {
serverId: string,
region: Region,
variantName: Variant,
firstMap?: string,
statusUpdater: StatusUpdater,
sourcemodAdminSteamId?: string,
extraEnvs?: Record<string, string>,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/usecase/CreateServerForClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ describe('CreateServerForClient', () => {
);
});

it('should pass firstMap to deployServer when provided', async () => {
// Given
const { sut, serverRepository, serverManager } = makeSut();
serverManager.deployServer.mockResolvedValue(deployedServer);
serverRepository.upsertServer.mockResolvedValue();

// When
await sut.execute({ ...baseArgs, firstMap: 'cp_process_f12' });

// Then
expect(serverManager.deployServer).toHaveBeenCalledWith(
expect.objectContaining({ firstMap: 'cp_process_f12' })
);
});

it('should pass empty extraEnvs to deployServer when not provided', async () => {
// Given
const { sut, serverRepository, serverManager } = makeSut();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/usecase/CreateServerForClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logger } from '@tf2qs/telemetry';
export type CreateServerForClientParams = {
region: Region;
variantName: Variant;
firstMap?: string;
clientId: string;
extraEnvs?: Record<string, string>;
statusUpdater?: StatusUpdater;
Expand Down Expand Up @@ -53,6 +54,7 @@ export class CreateServerForClient {
serverId,
region: args.region,
variantName: args.variantName,
firstMap: args.firstMap,
extraEnvs: args.extraEnvs ?? {},
statusUpdater,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ describe('POST /api/servers', () => {
const { app, backgroundTaskQueue } = makeSut();
backgroundTaskQueue.enqueue.mockResolvedValue('task-123');
const extraEnvs = { STV_TITLE: 'My Server' };
const firstMap = 'cp_process_f12';

// When
await request(app)
.post('/api/servers')
.send({ region: 'eu-frankfurt-1', variantName: 'standard-competitive', extraEnvs });
.send({ region: 'eu-frankfurt-1', variantName: 'standard-competitive', extraEnvs, firstMap });

// Then
expect(backgroundTaskQueue.enqueue).toHaveBeenCalledWith(
Expand All @@ -40,6 +41,7 @@ describe('POST /api/servers', () => {
variantName: 'standard-competitive',
clientId: TEST_CLIENT_ID,
extraEnvs,
firstMap,
}),
undefined,
undefined,
Expand Down Expand Up @@ -75,6 +77,9 @@ describe('POST /api/servers', () => {
{ body: { region: 'us-east-1', variantName: 'standard-competitive', extraEnvs: ['invalid'] }, description: 'extraEnvs is an array' },
{ body: { region: 'invalid-region', variantName: 'standard-competitive' }, description: 'region is not a valid Region enum value' },
{ body: { region: 'us-east-1', variantName: 'standard-competitive', extraEnvs: { KEY: 123 } }, description: 'extraEnvs has non-string values' },
{ body: { region: 'us-east-1', variantName: 'standard-competitive', firstMap: 123 }, description: 'firstMap is not a string' },
{ body: { region: 'us-east-1', variantName: 'standard-competitive', firstMap: ' ' }, description: 'firstMap is an empty string' },
{ body: { region: 'us-east-1', variantName: 'standard-competitive', firstMap: ';kill;' }, description: 'firstMap is a malicious injection string' },
])('should return 400 when $description', async ({ body }) => {
// Given
const { app } = makeSut();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ export function createCreateServerHandler(backgroundTaskQueue: BackgroundTaskQue
return;
}

const { region, variantName, extraEnvs } = req.body as {
const { region, variantName, extraEnvs, firstMap } = req.body as {
region?: unknown;
variantName?: unknown;
extraEnvs?: unknown;
firstMap?: unknown;
};

if (!region || typeof region !== 'string') {
Expand All @@ -59,6 +60,10 @@ export function createCreateServerHandler(backgroundTaskQueue: BackgroundTaskQue
res.status(400).json({ error: 'Bad Request', message: 'variantName is required and must be a string' });
return;
}
if (firstMap !== undefined && (typeof firstMap !== 'string' || !/^\w+$/.test(firstMap))) {
res.status(400).json({ error: 'Bad Request', message: 'Invalid map' });
return;
}

let sanitizedExtraEnvs: Record<string, string> | undefined;
if (extraEnvs !== undefined) {
Expand All @@ -82,6 +87,7 @@ export function createCreateServerHandler(backgroundTaskQueue: BackgroundTaskQue
variantName: variantName as Variant,
clientId: clientId as string,
extraEnvs: sanitizedExtraEnvs,
firstMap:firstMap as string | undefined,
};

const taskId = await backgroundTaskQueue.enqueue('create-server-for-client', taskData, undefined, undefined, { ownerId: clientId as string });
Expand Down
5 changes: 5 additions & 0 deletions packages/entrypoints/src/http/routes/swaggerOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export const swaggerOptions: swaggerJsdoc.Options = {
properties: {
region: { type: 'string', example: 'us-east-1', description: 'The AWS or OCI region to deploy in' },
variantName: { type: 'string', example: 'standard-competitive', description: 'The server variant to deploy' },
firstMap: {
type: 'string',
example: 'cp_process_f12',
description: 'Optional startup map override. If omitted, the variant default map is used.',
},
extraEnvs: {
type: 'object',
additionalProperties: { type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export class AWSServerManager implements ServerManager {
serverId: string;
region: Region;
variantName: Variant;
firstMap?: string;
statusUpdater: StatusUpdater;
sourcemodAdminSteamId?: string;
extraEnvs?: Record<string, string>;
Expand All @@ -99,6 +100,7 @@ export class AWSServerManager implements ServerManager {
serverId: args.serverId,
region: args.region,
variantName: args.variantName,
firstMap: args.firstMap,
statusUpdater: args.statusUpdater,
sourcemodAdminSteamId: args.sourcemodAdminSteamId,
extraEnvs: args.extraEnvs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,38 @@ describe("DefaultTaskDefinitionService", () => {
});
});

it("uses firstMap override when provided in deployment context", async () => {
const taskDefinitionArn = "arn:aws:ecs:us-east-1:123456789012:task-definition/test-server-123:1";
const contextWithFirstMap = {
...context,
firstMap: "koth_product_final"
} as DeploymentContext;

ecsClientMock.on(RegisterTaskDefinitionCommand).resolves({
taskDefinition: {
taskDefinitionArn
}
});

const service = new DefaultTaskDefinitionService(mockConfigManager, mockAWSConfigService, mockTracingService);

await service.create(contextWithFirstMap, credentials, environment);

const registerCalls = ecsClientMock.commandCalls(RegisterTaskDefinitionCommand);
expect(registerCalls).toHaveLength(1);

const command = registerCalls[0].args[0].input.containerDefinitions?.[0].command;
expect(command).toEqual([
"-enablefakeip",
"+sv_pure",
"2",
"+maxplayers",
"12",
"+map",
"koth_product_final"
]);
});

it("creates task definition successfully with NewRelic sidecar", async () => {
// Set up NewRelic environment variable
process.env.NEW_RELIC_LICENSE_KEY = "test-license-key";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class DefaultTaskDefinitionService implements TaskDefinitionServiceInterf
const awsRegionConfig = this.awsConfigService.getRegionConfig(context.region);
const { ecsClient } = this.awsConfigService.getClients(context.region);
const variantConfig = this.configManager.getVariantConfig(context.variantName);
const startupMap = context.firstMap ?? variantConfig.map;

this.tracingService.logOperationStart('Registering task definition', context.serverId, context.region);

Expand All @@ -61,7 +62,7 @@ export class DefaultTaskDefinitionService implements TaskDefinitionServiceInterf
"+maxplayers",
variantConfig.maxPlayers.toString(),
"+map",
variantConfig.map,
startupMap,
],
portMappings: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,27 @@ describe("OCIServerManager", () => {
expect(envVars.CUSTOM_ENV_VAR).toBe("custom-value");
expect(envVars.ANOTHER_VAR).toBe("another-value");
});

it("should use firstMap override instead of variant default map", async () => {
const { sut, containerClient } = createTestEnvironment();
const statusUpdater = vi.fn();

await sut.deployServer({
region: testRegion,
variantName: testVariant,
sourcemodAdminSteamId: "12345678901234567",
serverId: "test-server-first-map",
firstMap: "custom_map",
statusUpdater,
});

const containerInstanceRequest = containerClient.createContainerInstance.mock.calls[0][0];
const tf2Args = containerInstanceRequest.createContainerInstanceDetails.containers[0].arguments as string[];
const mapArgIndex = tf2Args.indexOf("+map");

expect(mapArgIndex).toBeGreaterThanOrEqual(0);
expect(tf2Args[mapArgIndex + 1]).toBe("custom_map");
});
});
describe("newrelic-infra sidecar", () => {
it("should add newrelic-infra container when NEW_RELIC_LICENSE_KEY is set", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export class OCIServerManager implements ServerManager {
serverId: string;
region: Region;
variantName: Variant;
firstMap?: string;
statusUpdater: StatusUpdater;
sourcemodAdminSteamId?: string;
extraEnvs?: Record<string, string>;
Expand All @@ -105,7 +106,7 @@ export class OCIServerManager implements ServerManager {
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, firstMap, sourcemodAdminSteamId, serverId, extraEnvs = {}, statusUpdater } = args;
const abortController = serverAbortManager.getOrCreate(serverId);
try {

Expand All @@ -124,6 +125,7 @@ export class OCIServerManager implements ServerManager {
const tvPassword = passwordGeneratorService.generatePassword(passwordSettings);

const containerImage = variantConfig.image;
const startupMap = firstMap ?? variantConfig.map;
const logSecret = chance.integer({ min: 1, max: 999999 })

const defaultCfgsEnvironment = variantConfig.defaultCfgs
Expand Down Expand Up @@ -193,7 +195,7 @@ export class OCIServerManager implements ServerManager {
"+maxplayers",
variantConfig.maxPlayers.toString(),
"+map",
variantConfig.map,
startupMap,
"+log",
"on",
"+logaddress_add",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,34 @@ describe('OracleVMManager', () => {
expect(tf2Service.cap_add).toContain('ALL');
});

it('should override startup map when firstMap is provided', async () => {
const { sut, data, capturedLaunchInstanceParams } = createTestEnvironment();
const statusUpdater = vi.fn() as StatusUpdater;

await sut.deployServer({
serverId: data.serverId,
region: data.testRegion,
variantName: data.testVariant,
firstMap: 'koth_product_final',
statusUpdater
});

const params = capturedLaunchInstanceParams();
const cloudInitScript = Buffer.from(params.userDataBase64, 'base64').toString('utf-8');
const dockerComposeContent = cloudInitScript
.split('content: |')[1]
.split('\n')
.filter((line) => line.trim() && !line.startsWith('#'))
.map((line) => line.replace(/^\s{6}/, ''))
.join('\n');

const dockerCompose = yaml.parse(dockerComposeContent);
const tf2Command = dockerCompose.services['tf2-server'].command as string;

expect(tf2Command).toContain('+map koth_product_final');
expect(tf2Command).not.toContain(`+map ${data.variantConfig.map}`);
});

it('should return all expected parameters in a successful run', async () => {
const { sut, data } = createTestEnvironment();
const statusUpdater = vi.fn() as StatusUpdater;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class OracleVMManager implements ServerManager {
serverId: string;
region: Region;
variantName: Variant;
firstMap?: string;
statusUpdater: StatusUpdater;
sourcemodAdminSteamId?: string;
extraEnvs?: Record<string, string>;
Expand All @@ -81,7 +82,7 @@ export class OracleVMManager implements ServerManager {
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, firstMap, sourcemodAdminSteamId, serverId, extraEnvs = {}, statusUpdater } = args;
const abortController = serverAbortManager.getOrCreate(serverId);
try {

Expand Down Expand Up @@ -140,6 +141,7 @@ export class OracleVMManager implements ServerManager {
const dockerComposeYaml = generateDockerCompose({
serverId,
variantConfig,
firstMap,
environmentVariables,
containerImage,
rconPassword,
Expand Down
Loading
Loading