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
16 changes: 9 additions & 7 deletions .github/workflows/sync-schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Synchronize - Schema

on:
schedule:
- cron: '0 8 * * *'
- cron: "0 8 * * *"
workflow_dispatch:

jobs:
Expand All @@ -20,7 +20,9 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
dotnet-version: "8.0.x"

- uses: denoland/setup-deno@v2

- name: Install lftp
run: sudo apt-get update && sudo apt-get install -y lftp
Expand All @@ -37,9 +39,9 @@ jobs:
mkdir -p ~/.lftp
echo "set sftp:auto-confirm yes" >> ~/.lftp/rc
echo "set ssl:verify-certificate no" >> ~/.lftp/rc
chmod +x eng/update-schema.sh
chmod +x eng/update-schema.ts
chmod +x eng/rcon
./eng/update-schema.sh
./eng/update-schema.ts

- name: Get patch version
id: version
Expand Down Expand Up @@ -68,17 +70,17 @@ jobs:
title: "chore: Update Schema Definitions to ${{ steps.version.outputs.patch_version }}"
body: |
This PR was automatically generated by the schema update workflow.

## Changes
- Updated schema definitions from the game server
- Regenerated schema code using `CounterStrikeSharp.SchemaGen`
- Patch Version: `${{ steps.version.outputs.patch_version }}`

---
*This is an automated PR. Please review the changes before merging.*
branch: chore/automated/schema-update
delete-branch: true

- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'
run: echo "::notice::No schema changes detected. No PR will be created."
run: echo "::notice::No schema changes detected. No PR will be created."
162 changes: 162 additions & 0 deletions eng/run-benchmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env --allow-net --allow-write

import $ from "https://deno.land/x/dax@0.39.2/mod.ts";
import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";

const env = { ...Deno.env.toObject(), ...await load({ export: true }) };

const config = z.object({
GS_HOST: z.string().min(1),
GS_PORT: z.string().min(1),
GS_PASS: z.string().min(1),
SFTP_HOST: z.string().min(1),
SFTP_USER: z.string().min(1),
SFTP_PASS: z.string().min(1),
GS_PLUGIN_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/plugins/NativeTestsPlugin"),
GS_API_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/api"),
GS_NATIVE_DIR: z.string().default("/game/csgo/addons/counterstrikesharp/bin/linuxsteamrt64"),
PTERO_URL: z.string().min(1),
PTERO_API_KEY: z.string().min(1),
PTERO_SERVER_ID: z.string().min(1),
GS_RESTART_TIMEOUT: z.string().default("120"),
}).parse(env);

const HERE = $.path(import.meta).parentOrThrow();
const ROOT = HERE.parentOrThrow();

const paths = {
apiProject: ROOT.join("managed/CounterStrikeSharp.API"),
apiBuild: ROOT.join("managed/CounterStrikeSharp.API/bin/Release/net8.0"),
testProject: ROOT.join("managed/CounterStrikeSharp.Tests.Native/NativeTestsPlugin.csproj"),
testBuild: ROOT.join("managed/CounterStrikeSharp.Tests.Native/bin/Debug/net8.0"),
nativeSo: ROOT.join("build/addons/counterstrikesharp/bin/linuxsteamrt64/counterstrikesharp.so"),
results: ROOT.join("TestResults/Benchmarks"),
};

const rcon = `${HERE}/rcon`;
const remoteJson = `${config.GS_PLUGIN_DIR}/benchmark-results.json`;
const remoteMd = `${config.GS_PLUGIN_DIR}/benchmark-results.md`;

async function poll(timeoutSec: number, intervalMs: number, label: string, check: () => Promise<void>) {
const start = Date.now();
while (Date.now() - start < timeoutSec * 1000) {
try {
await check();
return;
} catch {
const elapsed = Math.round((Date.now() - start) / 1000);
$.logLight(` ${label} (${elapsed}s elapsed)...`);
}
await $.sleep(intervalMs);
}
throw new Error(`${label}: timed out after ${timeoutSec}s`);
}

// ── Build ───────────────────────────────────────────────────────────────

$.logStep("Building API...");
await $`dotnet build -c Release`.cwd(paths.apiProject.toString());

$.logStep("Building NativeTestsPlugin...");
await $`dotnet build ${paths.testProject} -c Debug`;

// ── Upload ──────────────────────────────────────────────────────────────

$.logStep("Uploading API...");
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_API_DIR}; mirror -R --delete ${paths.apiBuild} ${config.GS_API_DIR}; bye`}`.quiet();

if (await Deno.stat(paths.nativeSo.toString()).then(() => true).catch(() => false)) {
$.logStep("Uploading native .so...");
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_NATIVE_DIR}; put ${paths.nativeSo} -o ${config.GS_NATIVE_DIR}/counterstrikesharp.so; bye`}`.quiet();
}

$.logStep("Uploading NativeTestsPlugin...");
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; mkdir -p ${config.GS_PLUGIN_DIR}; mirror -R --delete ${paths.testBuild} ${config.GS_PLUGIN_DIR}; bye`}`.quiet();

// ── Restart server ──────────────────────────────────────────────────────

const pteroHeaders = {
"Authorization": `Bearer ${config.PTERO_API_KEY}`,
"Content-Type": "application/json",
"Accept": "application/json",
};

$.logStep("Restarting game server...");
const resp = await fetch(`${config.PTERO_URL}/api/client/servers/${config.PTERO_SERVER_ID}/power`, {
method: "POST",
headers: pteroHeaders,
body: JSON.stringify({ signal: "restart" }),
});
if (!resp.ok) {
$.logError(`Restart failed (${resp.status}): ${await resp.text()}`);
Deno.exit(1);
}

await $.sleep(10_000);

const restartTimeout = parseInt(config.GS_RESTART_TIMEOUT, 10);
await poll(restartTimeout, 5_000, "Server not ready yet", async () => {
const out = await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "status"`.text();
if (!out) throw new Error();
});

$.logStep("Server is back online.");
await $.sleep(5_000);

// ── Run benchmarks ──────────────────────────────────────────────────────

$.logStep("Loading plugin...");
try {
await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins load NativeTestsPlugin"`.text();
} catch {
await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "css_plugins reload NativeTestsPlugin"`.text();
}

// Delete stale results so we can detect when new ones land
$.logStep("Clearing old results...");
try {
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`rm -f ${remoteJson}; rm -f ${remoteMd}; bye`}`.quiet();
} catch { /* may not exist */ }

$.logStep("Running: css_itest benchmark");
console.log(await $`${rcon} -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} -T 120s "css_itest benchmark"`.text());

// Async benchmarks (entity creation) span multiple frames, so the RCON
// command returns before results are written. Poll until the file appears.
await poll(300, 5_000, "Waiting for results", async () => {
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`cat ${remoteJson}; bye`}`.quiet();
});

await $.sleep(2_000); // let the .md flush too

// ── Download results ────────────────────────────────────────────────────

$.logStep("Downloading results...");
await Deno.mkdir(paths.results.toString(), { recursive: true });

const localJson = paths.results.join("benchmark-results.json").toString();
const localMd = paths.results.join("benchmark-results.md").toString();

await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${`set xfer:clobber on; get ${remoteJson} -o ${localJson}; get ${remoteMd} -o ${localMd}; bye`}`.quiet();

// Stamp git metadata into the results (only available on the build machine)
const gitBranch = (await $`git -C ${ROOT} rev-parse --abbrev-ref HEAD`.text()).trim();
const gitCommit = (await $`git -C ${ROOT} rev-parse --short HEAD`.text()).trim();

const report = JSON.parse(await Deno.readTextFile(localJson));
report.gitBranch = gitBranch;
report.gitCommit = gitCommit;
await Deno.writeTextFile(localJson, JSON.stringify(report) + "\n");

// Prepend git info to the markdown too
let md = await Deno.readTextFile(localMd);
md = md.replace(
"# Benchmark Results\n",
`# Benchmark Results\n\n- **Branch:** ${gitBranch}\n- **Commit:** ${gitCommit}\n`,
);
await Deno.writeTextFile(localMd, md);

$.logLight(` JSON: ${localJson}`);
$.logLight(` MD: ${localMd}`);
console.log("\n" + md);
40 changes: 40 additions & 0 deletions eng/update-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env --allow-net

import $ from "https://deno.land/x/dax@0.39.2/mod.ts";
import { load } from "https://deno.land/std@0.224.0/dotenv/mod.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";

const env = { ...Deno.env.toObject(), ...await load({ export: true }) };

const config = z.object({
// Game server RCON details
GS_HOST: z.string().min(1, "GS_HOST env var is required"),
GS_PORT: z.string().min(1, "GS_PORT env var is required"),
GS_PASS: z.string().min(1, "GS_PASS env var is required"),
// SFTP connection details
SFTP_HOST: z.string().min(1, "SFTP_HOST env var is required"),
SFTP_USER: z.string().min(1, "SFTP_USER env var is required"),
SFTP_PASS: z.string().min(1, "SFTP_PASS env var is required"),
}).parse(env);

const HERE = $.path(import.meta).parentOrThrow();

$.logStep("Dumping schema from game server via RCON...");
const output = await $`${HERE}/rcon -a ${config.GS_HOST}:${config.GS_PORT} -p ${config.GS_PASS} "dump_schema all"`.text();

// Extract the file path from RCON output
const match = output.match(/Wrote file output to (.+)/);
if (!match) {
throw new Error("Could not find schema output path in RCON response");
}
const filepath = match[1].trim();
const trimmedPath = filepath.replace(/^\/home\/container/, "");

$.logStep("Downloading schema file from game server via SFTP...");
const schemaOutput = `${HERE}/../managed/CounterStrikeSharp.SchemaGen/Schema/server.json`;
await $`lftp -u ${config.SFTP_USER},${config.SFTP_PASS} ${config.SFTP_HOST} -e ${"set xfer:clobber on; get " + trimmedPath + " -o " + schemaOutput + "; bye"}`.quiet();

const schemaGenProject = `${HERE}/../managed/CounterStrikeSharp.SchemaGen/CounterStrikeSharp.SchemaGen.csproj`;
const generatedSchemaDir = `${HERE}/../managed/CounterStrikeSharp.API/Generated/Schema`;
$.logStep("Generating C# schema classes...");
await $`dotnet run --project ${schemaGenProject} -- ${generatedSchemaDir}`;
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ public class NativeTestsPlugin : BasePlugin

public static int gameThreadId;

public static NativeTestsPlugin Instance { get; private set; } = null!;

public override void Load(bool hotReload)
{
gameThreadId = Thread.CurrentThread.ManagedThreadId;
Instance = this;
// Loading blocks the game thread, so we use NextFrame to run our tests asynchronously.
// Uncomment to run the tests on load
// Server.NextWorldUpdate(() => RunTests());
Expand Down Expand Up @@ -135,6 +138,9 @@ public async Task RunTests(string? filter = null)
Console.WriteLine($"[{ModuleName}] Test run finished.");
Console.WriteLine(reporter.GetSummary());
Console.WriteLine("*****************************************************************");

// Export benchmark results if any were collected
ScriptContextBenchmarks.ExportResults();
}
catch (Exception ex)
{
Expand Down
Loading
Loading